From 46f022f606a4a3fe528f7554a9bda1fd2c56eaf2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:17:05 -0700 Subject: [PATCH] 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 --- src/main/ipc/pty.test.ts | 55 ++++++++++++++++------ src/main/ipc/pty.ts | 13 +++-- src/main/pty/terminal-cwd-realpath.test.ts | 43 +++++++++++++++++ src/main/pty/terminal-cwd-realpath.ts | 30 ++++++++++++ src/main/runtime/orca-runtime.ts | 20 ++++++-- src/shared/terminal-startup-cwd.test.ts | 52 ++++++++++++++++++++ src/shared/terminal-startup-cwd.ts | 46 ++++++++++++++++-- 7 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 src/main/pty/terminal-cwd-realpath.test.ts create mode 100644 src/main/pty/terminal-cwd-realpath.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 71f742a7149..1f6137296ab 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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' } }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9538f246415..8a2c109f744 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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) diff --git a/src/main/pty/terminal-cwd-realpath.test.ts b/src/main/pty/terminal-cwd-realpath.test.ts new file mode 100644 index 00000000000..47110648b19 --- /dev/null +++ b/src/main/pty/terminal-cwd-realpath.test.ts @@ -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) + }) +}) diff --git a/src/main/pty/terminal-cwd-realpath.ts b/src/main/pty/terminal-cwd-realpath.ts new file mode 100644 index 00000000000..f9131d9f01a --- /dev/null +++ b/src/main/pty/terminal-cwd-realpath.ts @@ -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 +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5ae0c67d065..f349ec06b35 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { 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, + requestedCwd?: string | null + ): string | undefined { + return resolveTerminalStartupCwd(workspace.path, requestedCwd, { + canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId) + }) + } + private async resolveTerminalWorkspaceLaunchScope( selector: string ): Promise { diff --git a/src/shared/terminal-startup-cwd.test.ts b/src/shared/terminal-startup-cwd.test.ts index e6ac5575ceb..f87ef7d51ce 100644 --- a/src/shared/terminal-startup-cwd.test.ts +++ b/src/shared/terminal-startup-cwd.test.ts @@ -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({ diff --git a/src/shared/terminal-startup-cwd.ts b/src/shared/terminal-startup-cwd.ts index d71a0f7c37d..b71b737fa43 100644 --- a/src/shared/terminal-startup-cwd.ts +++ b/src/shared/terminal-startup-cwd.ts @@ -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(