From dd97923bb6d524c1a3fe42bde6d36ccca1f89df1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:56:48 -0700 Subject: [PATCH] fix(codex): validate linked worktree trust roots --- src/main/agent-trust-presets.test.ts | 35 +++++++++++++++++-- src/main/agent-trust-presets.ts | 34 +++++++++++++----- src/main/ipc/pty.ts | 2 +- .../window/attach-main-window-services.ts | 8 ++--- 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 16031090be8..2a979c60e27 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -137,7 +137,7 @@ describe('markCopilotFolderTrusted', () => { }) describe('markCodexProjectTrusted', () => { - it('trusts the main repository root for a linked worktree', () => { + it('trusts the main repository root for a linked worktree without reading commondir', () => { const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-')) const repository = join(fixtureRoot, 'repo') const workspace = join(fixtureRoot, 'worktrees', 'feature') @@ -146,7 +146,7 @@ describe('markCodexProjectTrusted', () => { mkdirSync(worktreeGitDir, { recursive: true }) mkdirSync(workspace, { recursive: true }) writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf-8') - writeFileSync(join(worktreeGitDir, 'commondir'), '../..\n', 'utf-8') + writeFileSync(join(worktreeGitDir, 'gitdir'), join(workspace, '.git'), 'utf-8') markCodexProjectTrusted(workspace) @@ -171,6 +171,37 @@ describe('markCodexProjectTrusted', () => { } }) + it('does not broaden trust through arbitrary or adversarial Git metadata', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-untrusted-gitdir-')) + const workspace = join(fixtureRoot, 'workspace') + const arbitraryGitDir = join(fixtureRoot, 'metadata', 'feature') + const unrelatedRoot = join(fixtureRoot, 'unrelated') + try { + mkdirSync(arbitraryGitDir, { recursive: true }) + mkdirSync(workspace, { recursive: true }) + mkdirSync(unrelatedRoot, { recursive: true }) + writeFileSync(join(workspace, '.git'), `gitdir: ${arbitraryGitDir}\n`, 'utf-8') + writeFileSync(join(arbitraryGitDir, 'commondir'), join(unrelatedRoot, '.git'), 'utf-8') + + markCodexProjectTrusted(workspace) + const structuredGitDir = join(unrelatedRoot, '.git', 'worktrees', 'feature') + mkdirSync(structuredGitDir, { recursive: true }) + writeFileSync(join(workspace, '.git'), `gitdir: ${structuredGitDir}\n`, 'utf-8') + writeFileSync(join(structuredGitDir, 'gitdir'), join(unrelatedRoot, '.git'), 'utf-8') + markCodexProjectTrusted(workspace) + + const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8') + expect(written).toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` + ) + expect(written).not.toContain( + `[projects."${escapeTomlBasicString(realpathSync.native(unrelatedRoot))}"]` + ) + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }) + } + }) + it('writes ~/.codex/config.toml with the project marked trusted', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) try { diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 3751c0871ac..3b441d6d1fe 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' @@ -120,18 +120,34 @@ export function markCodexProjectTrusted(workspacePath: string): void { function resolveCodexProjectTrustRoot(workspacePath: string): string { const absPath = canonicalize(workspacePath) try { - const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8') - const gitDirMatch = /^gitdir:\s*(.+?)\s*$/im.exec(gitDirReference) - if (!gitDirMatch?.[1]) { + const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8').trim() + if (!gitDirReference.startsWith('gitdir:')) { return absPath } - const gitDir = resolve(absPath, gitDirMatch[1]) - const commonDirReference = readFileSync(join(gitDir, 'commondir'), 'utf-8').trim() - if (!commonDirReference) { + const gitDirPath = gitDirReference.slice('gitdir:'.length).trim() + if (!gitDirPath) { return absPath } - // Why: Codex resolves linked-worktree trust through commondir to the main repo root. - return canonicalize(dirname(resolve(gitDir, commonDirReference))) + const gitDir = resolve(absPath, gitDirPath) + const worktreesDir = dirname(gitDir) + if (basename(worktreesDir) !== 'worktrees') { + return absPath + } + // Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link. + const gitDirBacklink = readFileSync(join(gitDir, 'gitdir'), 'utf-8').trim() + if (!gitDirBacklink) { + return absPath + } + const resolvedBacklink = resolve(gitDir, gitDirBacklink) + const workspaceGitFile = join(absPath, '.git') + if ( + resolvedBacklink !== workspaceGitFile && + canonicalize(resolvedBacklink) !== canonicalize(workspaceGitFile) + ) { + return absPath + } + // Why: mirror Codex's validated .git/worktrees/ traversal instead of trusting arbitrary commondir contents. + return canonicalize(dirname(dirname(worktreesDir))) } catch { return absPath } diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 49209472f36..0d0b79b6b68 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -619,7 +619,7 @@ function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): stri return keysToDelete } -type GetSelectedCodexHomePath = ( +export type GetSelectedCodexHomePath = ( target?: CodexAccountSelectionTarget, launchEnv?: NodeJS.ProcessEnv, launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 82c93f809cc..55f1265ea48 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -12,7 +12,7 @@ import type { import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' -import { getLocalPtyProvider, registerPtyHandlers } from '../ipc/pty' +import { getLocalPtyProvider, registerPtyHandlers, type GetSelectedCodexHomePath } from '../ipc/pty' import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' @@ -39,7 +39,6 @@ import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay' -import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' import { @@ -66,10 +65,7 @@ export function attachMainWindowServices( mainWindow: BrowserWindow, store: Store, runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: ( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv - ) => string | null, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, prepareClaudeAuth?: ( target?: ClaudeAccountSelectionTarget ) => Promise,