Validate terminal startup CWD paths against symlink escapes

Resolve terminal CWD and worktree paths to their canonical form using
realpath before validating containment. This blocks symlink escape
routes for local terminal spawns, returning the default workspace
directory if a requested path attempts to escape the worktree.

- Local terminal paths are canonicalized using native realpath logic
- WSL UNC paths and SSH/remote terminals skip canonicalization
- Floating terminal startup directories bypass containment checks
- Missing or unresolvable workspace directories default safely
This commit is contained in:
Jinjing
2026-07-04 01:17:05 -07:00
parent 0982882436
commit 46f022f606
7 changed files with 232 additions and 27 deletions
+41 -14
View File
@@ -9,6 +9,7 @@ import {
TERMINAL_INPUT_MAX_BYTES
} from '../../shared/terminal-input'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
const isWindowsHost = process.platform === 'win32'
const posixOnlyIt = isWindowsHost ? it.skip : it
@@ -1307,6 +1308,7 @@ describe('registerPtyHandlers', () => {
// from main and PR #2662 command threading for OMP target selection.
spawnArgs?: {
cwd?: string
worktreeId?: string
shellOverride?: string
command?: string
envToDelete?: string[]
@@ -1500,7 +1502,10 @@ describe('registerPtyHandlers', () => {
CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home',
ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
},
{ cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo' }
{
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo',
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\test\\repo'
}
)
const { env } = spawnOptions
expect(env.CODEX_HOME).toBeUndefined()
@@ -4348,7 +4353,7 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: { ORCA_PANE_KEY: paneKey },
@@ -4362,12 +4367,12 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: {
ORCA_TAB_ID: 'tab-race',
ORCA_WORKTREE_ID: 'wt-1'
ORCA_WORKTREE_ID: 'repo-1::/tmp'
}
}) as Promise<{ id: string }>
await Promise.resolve()
@@ -4380,7 +4385,7 @@ describe('registerPtyHandlers', () => {
])
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-1',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
ptyId: 'pty-shared',
@@ -4462,13 +4467,13 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: {
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-race',
ORCA_WORKTREE_ID: 'wt-1'
ORCA_WORKTREE_ID: 'repo-1::/tmp'
}
}) as Promise<{ id: string }>
await Promise.resolve()
@@ -4478,7 +4483,7 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'wt-1',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
env: { ORCA_PANE_KEY: paneKey },
@@ -4494,7 +4499,7 @@ describe('registerPtyHandlers', () => {
])
expect(providerSpawn).toHaveBeenCalledTimes(1)
expect(store.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'wt-1',
worktreeId: 'repo-1::/tmp',
tabId: 'tab-race',
leafId,
ptyId: 'pty-renderer',
@@ -5838,6 +5843,22 @@ describe('registerPtyHandlers', () => {
})
})
it('passes floating terminal cwds through to the spawned shell', async () => {
// Why: the floating sentinel has no worktree root; its cwd is validated
// against trusted-directory grants before it reaches pty:spawn.
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp/floating-notes',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
})
const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }]
expect(options.cwd).toBe('/tmp/floating-notes')
})
it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => {
const originalPlatform = process.platform
const originalUserProfile = process.env.USERPROFILE
@@ -5848,8 +5869,10 @@ describe('registerPtyHandlers', () => {
})
process.env.USERPROFILE = 'C:\\Users\\jinwo'
// Why: the startup-cwd guard normalizes separators, so the provider sees
// the forward-slash UNC form.
existsSyncMock.mockImplementation((targetPath: string) => {
if (targetPath === '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') {
if (targetPath === '//wsl.localhost/Ubuntu/home/jin/missing') {
return false
}
return true
@@ -5862,10 +5885,11 @@ describe('registerPtyHandlers', () => {
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing'
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing',
worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\jin'
})
).rejects.toThrow(
'Working directory "\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing" does not exist.'
'Working directory "//wsl.localhost/Ubuntu/home/jin/missing" does not exist.'
)
expect(spawnMock).not.toHaveBeenCalled()
} finally {
@@ -7352,7 +7376,8 @@ describe('registerPtyHandlers', () => {
const result = await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
cwd: '/tmp',
worktreeId: 'repo-1::/tmp'
})
expect(result).toEqual({ id: expect.any(String), pid: 12345 })
@@ -7392,7 +7417,8 @@ describe('registerPtyHandlers', () => {
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
cwd: '/tmp',
worktreeId: 'repo-1::/tmp'
})
expect(spawnMock).toHaveBeenCalledTimes(1)
@@ -7708,6 +7734,7 @@ describe('registerPtyHandlers', () => {
cols: 80,
rows: 24,
cwd: '/tmp',
worktreeId: 'repo-1::/tmp',
env: { SHELL: '/opt/homebrew/bin/bash' }
})
+9 -4
View File
@@ -78,6 +78,7 @@ import {
parsePaneKey
} from '../../shared/stable-pane-id'
import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd'
import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
import {
clearMigrationUnsupportedPty,
clearMigrationUnsupportedPtysForPaneKey
@@ -1975,13 +1976,17 @@ export function registerPtyHandlers(
const resolveGuardedPtySpawnCwd = (
worktreeId: string | undefined,
cwd: string | undefined
cwd: string | undefined,
connectionId?: string | null
): string | undefined =>
resolveTerminalStartupCwdForWorkspace({
workspaceId: worktreeId,
requestedCwd: cwd,
resolveFolderWorkspacePath: (folderWorkspaceId) =>
store?.getFolderWorkspace(folderWorkspaceId)?.folderPath
store?.getFolderWorkspace(folderWorkspaceId)?.folderPath,
// Why: realpath only makes sense on the local filesystem; SSH worktree
// paths live on the remote host.
canonicalizePath: localTerminalCwdCanonicalizer(connectionId)
})
// Why: the runtime controller must route through getProviderForPty() so that
@@ -1994,7 +1999,7 @@ export function registerPtyHandlers(
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd)
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
@@ -2574,7 +2579,7 @@ export function registerPtyHandlers(
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd)
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
spawnTiming.mark('preflight')
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
@@ -0,0 +1,43 @@
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import {
canonicalizeLocalTerminalPath,
localTerminalCwdCanonicalizer
} from './terminal-cwd-realpath'
describe('canonicalizeLocalTerminalPath', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cwd-realpath-'))
afterAll(() => {
rmSync(root, { recursive: true, force: true })
})
it('resolves symlinks to their target', () => {
const outside = join(root, 'outside')
const worktree = join(root, 'worktree')
mkdirSync(outside)
mkdirSync(worktree)
const link = join(worktree, 'escape')
symlinkSync(outside, link, 'dir')
expect(canonicalizeLocalTerminalPath(link)).toBe(canonicalizeLocalTerminalPath(outside))
})
it('returns null for nonexistent paths', () => {
expect(canonicalizeLocalTerminalPath(join(root, 'does-not-exist'))).toBeNull()
})
it('skips WSL UNC paths instead of touching the 9P share', () => {
expect(canonicalizeLocalTerminalPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toBeNull()
expect(canonicalizeLocalTerminalPath('//wsl$/Ubuntu/home/jin/repo')).toBeNull()
})
})
describe('localTerminalCwdCanonicalizer', () => {
it('is disabled for SSH connections and enabled locally', () => {
expect(localTerminalCwdCanonicalizer('ssh-1')).toBeUndefined()
expect(localTerminalCwdCanonicalizer(null)).toBe(canonicalizeLocalTerminalPath)
expect(localTerminalCwdCanonicalizer(undefined)).toBe(canonicalizeLocalTerminalPath)
})
})
+30
View File
@@ -0,0 +1,30 @@
import { realpathSync } from 'node:fs'
import { isWslUncPath } from '../../shared/wsl-paths'
// Why: terminal startup cwd containment is string-based; realpath closes the
// symlink escape for local paths. Only for local worktrees — SSH/remote paths
// are not resolvable on this filesystem.
export function canonicalizeLocalTerminalPath(targetPath: string): string | null {
// Why: realpath over the WSL 9P share is unreliable and can block the main
// process while the distro boots; keep the string containment check only.
if (isWslUncPath(targetPath)) {
return null
}
try {
return realpathSync.native(targetPath)
} catch {
try {
// Why: realpathSync.native can fail on network/UNC mounts where the
// JS implementation still succeeds (same fallback as git repo scanning).
return realpathSync(targetPath)
} catch {
return null
}
}
}
export function localTerminalCwdCanonicalizer(
connectionId: string | null | undefined
): ((path: string) => string | null) | undefined {
return connectionId ? undefined : canonicalizeLocalTerminalPath
}
+16 -4
View File
@@ -202,6 +202,7 @@ import {
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd'
import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
import { isWslUncPath } from '../../shared/wsl-paths'
import {
folderWorkspaceKey,
@@ -15487,7 +15488,7 @@ export class OrcaRuntimeService {
throw new Error('runtime_unavailable')
}
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path
const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd) ?? workspace.path
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
// Why: mint tabId in main before spawn so paneKey is known at PTY env
// build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys
@@ -15672,7 +15673,7 @@ export class OrcaRuntimeService {
? await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
: null
const worktreeId = workspace?.id
const cwd = workspace ? resolveTerminalStartupCwd(workspace.path, opts.cwd) : opts.cwd
const cwd = workspace ? this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd) : opts.cwd
const requestId = randomUUID()
// Why: terminal creation is a renderer-side Zustand store operation (like
@@ -15815,7 +15816,7 @@ export class OrcaRuntimeService {
this.assertGraphReady()
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const worktreeId = workspace.id
const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd)
const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd)
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
let afterDesktopTabId: string | undefined
if (opts.afterTabId) {
@@ -16019,7 +16020,7 @@ export class OrcaRuntimeService {
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd)
const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd)
// Why: SshPtyProvider treats sessionId as a relay reattach request. Only
// synthesize local serve ids; SSH fresh terminals must call pty.spawn.
const stableSessionId =
@@ -16888,6 +16889,17 @@ export class OrcaRuntimeService {
}
}
// Why: every terminal-creation path must apply the same symlink-aware cwd
// guard; keep the canonicalizer wiring in one place.
private resolveGuardedWorkspaceTerminalCwd(
workspace: Pick<TerminalWorkspaceLaunchScope, 'path' | 'connectionId'>,
requestedCwd?: string | null
): string | undefined {
return resolveTerminalStartupCwd(workspace.path, requestedCwd, {
canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId)
})
}
private async resolveTerminalWorkspaceLaunchScope(
selector: string
): Promise<TerminalWorkspaceLaunchScope> {
+52
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from './constants'
import {
resolveTerminalStartupCwd,
resolveTerminalStartupCwdForWorkspace
@@ -28,6 +29,31 @@ describe('resolveTerminalStartupCwd', () => {
)
})
it('trims whitespace-padded requested cwds before resolving', () => {
expect(resolveTerminalStartupCwd('/repo/app', ' packages/web ')).toBe('/repo/app/packages/web')
})
it('falls back to the default cwd when a symlink escapes the worktree', () => {
const canonicalize = (path: string): string | null =>
path === '/repo/app/link' ? '/outside/target' : path
expect(
resolveTerminalStartupCwd('/repo/app', 'link', { canonicalizePath: canonicalize })
).toBeUndefined()
})
it('accepts cwds under a symlinked worktree root', () => {
const canonicalize = (path: string): string | null => path.replace(/^\/tmp\//, '/private/tmp/')
expect(
resolveTerminalStartupCwd('/tmp/repo', 'packages/web', { canonicalizePath: canonicalize })
).toBe('/tmp/repo/packages/web')
})
it('skips the symlink re-check when a path cannot be canonicalized', () => {
expect(
resolveTerminalStartupCwd('/repo/app', 'missing', { canonicalizePath: () => null })
).toBe('/repo/app/missing')
})
it('handles Windows path containment without case drift', () => {
expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'packages\\web')).toBe(
'C:/Repo/App/packages/web'
@@ -52,6 +78,32 @@ describe('resolveTerminalStartupCwd', () => {
).toThrow('Terminal cwd must be inside the selected worktree.')
})
it('passes floating terminal cwds through untouched', () => {
// Why: floating terminal cwds are validated against trusted-directory
// grants in main and have no worktree root to contain within.
expect(
resolveTerminalStartupCwdForWorkspace({
workspaceId: FLOATING_TERMINAL_WORKTREE_ID,
requestedCwd: '/Volumes/work/notes'
})
).toBe('/Volumes/work/notes')
})
it('refuses the requested cwd when no workspace root is resolvable', () => {
expect(
resolveTerminalStartupCwdForWorkspace({
workspaceId: undefined,
requestedCwd: '/anywhere'
})
).toBeUndefined()
expect(
resolveTerminalStartupCwdForWorkspace({
workspaceId: 'opaque-worktree-id',
requestedCwd: '/anywhere'
})
).toBeUndefined()
})
it('validates renderer PTY cwd values against folder workspace keys', () => {
expect(
resolveTerminalStartupCwdForWorkspace({
+41 -5
View File
@@ -1,20 +1,44 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from './constants'
import { isPathInsideOrEqual, resolveRuntimePath } from './cross-platform-path'
import { parseWorkspaceKey } from './workspace-scope'
import { splitWorktreeIdForFilesystem } from './worktree-id'
export type TerminalStartupCwdOptions = {
// Why: the string containment check can't see symlinks; only local callers
// can canonicalize — SSH worktree paths live on the remote host.
canonicalizePath?: (path: string) => string | null
}
export function resolveTerminalStartupCwd(
worktreePath: string,
requestedCwd?: string | null
requestedCwd?: string | null,
options?: TerminalStartupCwdOptions
): string | undefined {
if (!requestedCwd || requestedCwd.trim().length === 0) {
const trimmedCwd = requestedCwd?.trim()
if (!trimmedCwd) {
return undefined
}
const resolvedCwd = resolveRuntimePath(worktreePath, requestedCwd)
const resolvedCwd = resolveRuntimePath(worktreePath, trimmedCwd)
if (!isPathInsideOrEqual(worktreePath, resolvedCwd)) {
// Why: remote/session clients can request terminal cwd; never let that
// become a shell outside the selected workspace.
throw new Error('Terminal cwd must be inside the selected worktree.')
}
const canonicalizePath = options?.canonicalizePath
if (canonicalizePath) {
const canonicalWorktreePath = canonicalizePath(worktreePath)
const canonicalCwd = canonicalizePath(resolvedCwd)
if (
canonicalWorktreePath &&
canonicalCwd &&
!isPathInsideOrEqual(canonicalWorktreePath, canonicalCwd)
) {
// Why: a symlink escaping the worktree can be legitimate (e.g. a pnpm
// store link), so fall back to the default cwd instead of failing the
// spawn — but never grant the requested out-of-worktree shell.
return undefined
}
}
return resolvedCwd
}
@@ -22,18 +46,30 @@ export function resolveTerminalStartupCwdForWorkspace(args: {
workspaceId?: string
requestedCwd?: string | null
resolveFolderWorkspacePath?: (folderWorkspaceId: string) => string | null | undefined
canonicalizePath?: (path: string) => string | null
}): string | undefined {
if (!args.requestedCwd || args.requestedCwd.trim().length === 0) {
return undefined
}
if (args.workspaceId === FLOATING_TERMINAL_WORKTREE_ID) {
// Why: floating terminals have no worktree root to contain within; their
// cwd was already validated against the trusted-directory grants in
// resolveFloatingTerminalCwd.
return args.requestedCwd
}
const workspacePath = resolveTerminalWorkspacePath(
args.workspaceId,
args.resolveFolderWorkspacePath
)
if (!workspacePath) {
return args.requestedCwd
// Why: without a resolvable workspace root we cannot enforce the
// in-worktree containment check, so refuse the requested cwd rather
// than trusting an unvalidated path.
return undefined
}
return resolveTerminalStartupCwd(workspacePath, args.requestedCwd)
return resolveTerminalStartupCwd(workspacePath, args.requestedCwd, {
canonicalizePath: args.canonicalizePath
})
}
function resolveTerminalWorkspacePath(