stablyai/orca · critical
Access denied: submodule path escapes the selected worktree
Error message
Access denied: submodule path escapes the selected worktree
What it means
Second guard in resolveSubmoduleWorktreePath: after path.resolve(worktreePath, submodulePath), the function computes path.relative(worktreePath, resolved) and throws if it is empty, '..', starts with '..<sep>', or is absolute. A legitimate submodule path stays inside the parent worktree; anything else means the resolved target escaped and must be refused to prevent filesystem access outside the selected worktree.
Source
Thrown at src/main/git/status.ts:508
}
function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string {
// Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host.
return `${options.wslDistro ?? 'native'}\0${worktreePath}`
}
/**
* Resolve a submodule's own worktree path from a parent worktree + relative
* submodule path, rejecting anything that escapes the parent.
*/
export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath: string): string {
if (!submodulePath || submodulePath.includes('\0') || path.isAbsolute(submodulePath)) {
throw new Error('Access denied: invalid submodule path')
}
const resolved = path.resolve(worktreePath, submodulePath)
const rel = path.relative(worktreePath, resolved)
if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
throw new Error('Access denied: submodule path escapes the selected worktree')
}
return resolved
}
/**
* Run a plain status inside a submodule's own worktree (lazy "expand submodule"
* flow). Entry paths are relative to the submodule root; the renderer prefixes them.
*/
export async function getSubmoduleStatus(
worktreePath: string,
submodulePath: string,
options: GetStatusOptions & { staged?: boolean } = {}
): Promise<GitStatusResult> {
const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath)
const limit = resolveGitStatusLimit(options.limit)
// Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work.
const workingResult = options.staged
? ({ entries: [], conflictOperation: 'unknown' } satisfies GitStatusResult)View on GitHub (pinned to 1136503c6a)
Solutions
- Use the parent worktree's actual realpath as worktreePath so resolve() and relative() agree on a root.
- Source submodule paths from git submodule status / .gitmodules rather than constructing them yourself.
- If the parent worktree is symlinked, resolve the symlink once at the boundary and pass the real path everywhere downstream.
- Treat this throw as a hard stop — never sanitise the path to slip past it.
Example fix
// before
resolveSubmoduleWorktreePath(worktreePath, submodulePath) // worktreePath may be a symlink
// after: resolve the parent root first
import { realpath } from 'node:fs/promises'
const realWorktree = await realpath(worktreePath)
resolveSubmoduleWorktreePath(realWorktree, submodulePath) Defensive patterns
Strategy: validation
Validate before calling
import path from 'node:path'
import { realpath } from 'node:fs/promises'
async function submodulePathStaysInside(worktreePath: string, submodulePath: string): Promise<boolean> {
const root = await realpath(worktreePath)
const resolved = path.resolve(root, submodulePath)
const rel = path.relative(root, resolved)
return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
} Type guard
function isSubmodulePathEscapes(error: unknown): boolean {
return error instanceof Error && error.message === 'Access denied: submodule path escapes the selected worktree'
} Try / catch
const realWorktree = await realpath(worktreePath)
if (!(await submodulePathStaysInside(realWorktree, submodulePath))) {
throw new Error('Refuse to open submodule: resolved path escapes the worktree.')
}
try { return resolveSubmoduleWorktreePath(realWorktree, submodulePath) }
catch (error) { if (isSubmodulePathEscapes(error)) { showStatus('Cannot open submodule outside the worktree.'); return null } throw error } Prevention
- realpath() the parent worktree once at the boundary and pass the real path downstream.
- Source submodule paths from git's own listing, never construct them from raw user input.
- Treat this throw as a hard stop; never sanitise a traversal path to slip past it.
When it happens
Trigger: Calling resolveSubmoduleWorktreePath with a submodulePath like '../outside', '../../etc', a symlink-laden worktreePath that resolves outside itself, or a crafted relative path that climbs above the parent. Also via getSubmoduleStatus and the diff loaders that route through it.
Common situations: A submodule path that legitimately tries to escape (rare, but possible from corrupted .gitmodules); a symlinked parent worktree whose realpath differs from the stored worktree path; adversarial automation passing traversal strings; Windows junction/MSYS edge cases where resolve() lands on a different drive.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- Clone path must be inside the destination directory
- Access denied: invalid submodule path
- Path "${filePath}" resolves outside the worktree
- Access denied: invalid git file path
- Access denied: git file path escapes the selected worktree
AI-assisted analysis of stablyai/orca@1136503c6a (2026-08-12).
Data as JSON: /api/errors/d4ae058b45f99dce.
Report an issue: GitHub.