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

  1. Use the parent worktree's actual realpath as worktreePath so resolve() and relative() agree on a root.
  2. Source submodule paths from git submodule status / .gitmodules rather than constructing them yourself.
  3. If the parent worktree is symlinked, resolve the symlink once at the boundary and pass the real path everywhere downstream.
  4. 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

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

Related errors


AI-assisted analysis of stablyai/orca@1136503c6a (2026-08-12). Data as JSON: /api/errors/d4ae058b45f99dce. Report an issue: GitHub.