From 433356adcfef25ef0ab3958fca19649d3aa6e9fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:17:07 -0700 Subject: [PATCH] fix(codex): validate per-account home ownership --- .../host-codex-managed-home-ownership.test.ts | 86 +++++++++++++++++ .../host-codex-managed-home-ownership.ts | 96 +++++++++++++++++++ .../codex-accounts/runtime-home-service.ts | 54 +++++++++-- src/main/codex-accounts/service.test.ts | 12 ++- src/main/codex-accounts/service.ts | 50 ++-------- 5 files changed, 240 insertions(+), 58 deletions(-) create mode 100644 src/main/codex-accounts/host-codex-managed-home-ownership.test.ts create mode 100644 src/main/codex-accounts/host-codex-managed-home-ownership.ts diff --git a/src/main/codex-accounts/host-codex-managed-home-ownership.test.ts b/src/main/codex-accounts/host-codex-managed-home-ownership.test.ts new file mode 100644 index 00000000000..d5edda449a9 --- /dev/null +++ b/src/main/codex-accounts/host-codex-managed-home-ownership.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' + +describe('assertOwnedHostCodexManagedHomePath', () => { + let rootDir: string + let userDataDir: string + let systemHomePath: string + + beforeEach(() => { + rootDir = mkdtempSync(join(tmpdir(), 'orca-managed-home-ownership-')) + userDataDir = join(rootDir, 'user-data') + systemHomePath = join(rootDir, 'home', '.codex') + mkdirSync(join(userDataDir, 'codex-accounts'), { recursive: true }) + mkdirSync(systemHomePath, { recursive: true }) + }) + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }) + }) + + it('accepts the expected marked account home', () => { + const accountHome = join(userDataDir, 'codex-accounts', 'account-1', 'home') + mkdirSync(accountHome, { recursive: true }) + writeFileSync(join(accountHome, '.orca-managed-home'), 'account-1\n', 'utf-8') + + expect( + assertOwnedHostCodexManagedHomePath({ + candidatePath: accountHome, + managedAccountsRoot: join(userDataDir, 'codex-accounts'), + systemCodexHomePath: systemHomePath, + expectedAccountId: 'account-1' + }) + ).toBe(realpathSync(accountHome)) + }) + + it('rejects an account path redirected into the sandboxed system Codex home', () => { + const systemAccountHome = join(systemHomePath, 'account-1', 'home') + mkdirSync(systemAccountHome, { recursive: true }) + writeFileSync(join(systemAccountHome, '.orca-managed-home'), 'account-1\n', 'utf-8') + const sentinelPath = join(systemHomePath, 'auth.json') + writeFileSync(sentinelPath, 'system-auth\n', 'utf-8') + + rmSync(join(userDataDir, 'codex-accounts'), { recursive: true, force: true }) + symlinkSync( + systemHomePath, + join(userDataDir, 'codex-accounts'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + expect(() => + assertOwnedHostCodexManagedHomePath({ + candidatePath: join(userDataDir, 'codex-accounts', 'account-1', 'home'), + managedAccountsRoot: join(userDataDir, 'codex-accounts'), + systemCodexHomePath: systemHomePath, + expectedAccountId: 'account-1' + }) + ).toThrow('resolves inside the system Codex home') + expect(readFileSync(sentinelPath, 'utf-8')).toBe('system-auth\n') + }) + + it('rejects a persisted home or marker belonging to another account', () => { + const accountHome = join(userDataDir, 'codex-accounts', 'account-2', 'home') + mkdirSync(accountHome, { recursive: true }) + writeFileSync(join(accountHome, '.orca-managed-home'), 'account-2\n', 'utf-8') + + expect(() => + assertOwnedHostCodexManagedHomePath({ + candidatePath: accountHome, + managedAccountsRoot: join(userDataDir, 'codex-accounts'), + systemCodexHomePath: systemHomePath, + expectedAccountId: 'account-1' + }) + ).toThrow('does not match its persisted account ID') + }) +}) diff --git a/src/main/codex-accounts/host-codex-managed-home-ownership.ts b/src/main/codex-accounts/host-codex-managed-home-ownership.ts new file mode 100644 index 00000000000..6f0603c73f4 --- /dev/null +++ b/src/main/codex-accounts/host-codex-managed-home-ownership.ts @@ -0,0 +1,96 @@ +import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' + +type HostCodexManagedHomeOwnershipOptions = { + candidatePath: string + managedAccountsRoot: string + systemCodexHomePath: string + expectedAccountId?: string +} + +function pathsEqual(left: string, right: string): boolean { + const resolvedLeft = resolve(left) + const resolvedRight = resolve(right) + return process.platform === 'win32' + ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() + : resolvedLeft === resolvedRight +} + +function pathIsInsideOrEqual(rootPath: string, candidatePath: string): boolean { + const relativePath = relative(rootPath, candidatePath) + return ( + relativePath === '' || + (!isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`)) + ) +} + +function canonicalizeIfPresent(candidatePath: string): string { + const resolvedPath = resolve(candidatePath) + return existsSync(resolvedPath) ? realpathSync(resolvedPath) : resolvedPath +} + +/** + * Proves a host managed home is an Orca-owned account directory and cannot + * resolve into the user's real CODEX_HOME before callers write launch state. + */ +export function assertOwnedHostCodexManagedHomePath({ + candidatePath, + managedAccountsRoot, + systemCodexHomePath, + expectedAccountId +}: HostCodexManagedHomeOwnershipOptions): string { + const resolvedCandidate = resolve(candidatePath) + const resolvedRoot = resolve(managedAccountsRoot) + if (!existsSync(resolvedCandidate)) { + throw new Error('Managed Codex home directory does not exist on disk.') + } + // Why: macOS can spell one path as /var and /private/var, while a replaced + // path component must still be caught as a canonical containment escape. + const canonicalCandidate = realpathSync(resolvedCandidate) + const canonicalRoot = realpathSync(resolvedRoot) + const canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath) + if (expectedAccountId !== undefined) { + const candidateUsesManagedRootSpelling = + pathIsInsideOrEqual(resolvedRoot, resolvedCandidate) || + pathIsInsideOrEqual(canonicalRoot, resolvedCandidate) + const canonicalExpectedHome = canonicalizeIfPresent( + join(canonicalRoot, expectedAccountId, 'home') + ) + if ( + !candidateUsesManagedRootSpelling || + !pathsEqual(canonicalCandidate, canonicalExpectedHome) + ) { + throw new Error('Managed Codex home does not match its persisted account ID.') + } + } + // Why: a replaced codex-accounts directory could otherwise redirect config, + // hook, or resource writes into the user's real ~/.codex tree. + if (pathIsInsideOrEqual(canonicalSystemHome, canonicalCandidate)) { + throw new Error('Managed Codex home resolves inside the system Codex home.') + } + if ( + !pathIsInsideOrEqual(canonicalRoot, canonicalCandidate) || + canonicalRoot === canonicalCandidate + ) { + throw new Error( + `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).` + ) + } + + const markerPath = join(canonicalCandidate, '.orca-managed-home') + let markerIsRegularFile: boolean + try { + markerIsRegularFile = lstatSync(markerPath).isFile() + } catch (error) { + throw new Error('Managed Codex home is missing Orca ownership marker.', { cause: error }) + } + if (!markerIsRegularFile) { + throw new Error('Managed Codex home ownership marker is not a regular file.') + } + const markerContents = readFileSync(markerPath, 'utf-8') + if (expectedAccountId !== undefined && markerContents.trim() !== expectedAccountId) { + throw new Error('Managed Codex home ownership marker does not match its account ID.') + } + + return canonicalCandidate +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index d3952aedca5..7535b1109e2 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -65,6 +65,7 @@ import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-fl import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path' import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { readShellStartupEnvVar } from '../pty/shell-startup-env' +import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' type CodexAuthIdentity = { email: string | null @@ -230,11 +231,11 @@ export class CodexRuntimeHomeService { } private prepareSelfContainedManagedHomeForLaunch(account: CodexManagedAccount): string | null { - const perAccountHome = account.managedHomePath - if (!existsSync(join(perAccountHome, 'auth.json'))) { + const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account) + if (!perAccountHome || !existsSync(join(perAccountHome, 'auth.json'))) { // Why: drop the selection so this and future launches resolve to the // system default rather than a home codex cannot authenticate against. - this.syncSelfContainedManagedSelection(account) + this.clearSelfContainedManagedSelection(account) return null } // Why: link the user's real ~/.codex resources and mirror config into THIS @@ -254,14 +255,39 @@ export class CodexRuntimeHomeService { // hot-swap or token read-back to reconcile. Only validate the credential // survives; a vanished auth.json drops the selection to the system default. private syncSelfContainedManagedSelection(account: CodexManagedAccount): void { - if (existsSync(join(account.managedHomePath, 'auth.json'))) { + const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account) + if (perAccountHome && existsSync(join(perAccountHome, 'auth.json'))) { this.lastSyncedAccountId = account.id return } + this.clearSelfContainedManagedSelection(account) + } + + private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { + try { + assertOwnedHostCodexManagedHomePath({ + candidatePath: account.managedHomePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) + // Preserve the persisted path spelling (notably /var vs /private/var on + // macOS) so injected CODEX_HOME stays stable across the rollout. + return account.managedHomePath + } catch (error) { + console.warn('[codex-runtime-home] Refusing untrusted managed account home:', error) + return null + } + } + + private clearSelfContainedManagedSelection(account: CodexManagedAccount): void { console.warn( - '[codex-runtime-home] Active managed account is missing auth.json, clearing selection' + '[codex-runtime-home] Active managed account home is invalid or missing auth.json, clearing selection' ) const settings = this.store.getSettings() + if (normalizeCodexRuntimeSelection(settings).host !== account.id) { + return + } this.store.updateSettings({ activeCodexManagedAccountId: null, activeCodexManagedAccountIdsByRuntime: { @@ -316,9 +342,10 @@ export class CodexRuntimeHomeService { getHostCodexHomePathsForSessionDiscovery(): string[] { const homes = [this.getRuntimeHomePath()] - if (this.isHostSystemDefaultRealHome()) { - // Why: nested Orca processes can retain an ambient managed CODEX_HOME; - // explicitly include the real lane so its sessions remain discoverable. + if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { + // Why: nested Orca processes can retain an ambient managed CODEX_HOME. + // Per-account lanes no longer bridge real-home history into the shared + // mirror, so include the real root for both directly-routed host lanes. homes.push(getSystemCodexHomePath()) } // Why: flag ON routes each managed host account to its own self-contained @@ -434,14 +461,21 @@ export class CodexRuntimeHomeService { return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } const selfContainedAccount = this.getSelfContainedManagedHostAccount() + const selfContainedHome = selfContainedAccount + ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + : null if ( selfContainedAccount && - existsSync(join(selfContainedAccount.managedHomePath, 'auth.json')) + selfContainedHome && + existsSync(join(selfContainedHome, 'auth.json')) ) { // Why: the quota fetch reads the account's own auth.json in place; no // shared-home hot-swap, and no per-poll resource relink (that is launch // prep). Config was mirrored on add/select and refreshed at launch. - return selfContainedAccount.managedHomePath + return selfContainedHome + } + if (selfContainedAccount) { + this.clearSelfContainedManagedSelection(selfContainedAccount) } if (this.isHostSystemDefaultRealHome()) { // Why: null lets the fetcher fall back to the main process's inherited diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 0f990c5b2ff..846469efab2 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -344,7 +344,7 @@ describe('CodexAccountService config sync', () => { ) }) - it('strips only Orca-owned source-home hook trust on startup and account selection', async () => { + it('does not seed source-home hook trust into a self-contained account home', async () => { const fixture = await createCanonicalHookTrustFixture() const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') writeFileSync(canonicalConfigPath, fixture.config, 'utf-8') @@ -354,6 +354,7 @@ describe('CodexAccountService config sync', () => { 'approval_policy = "on-request"\n' ) const settings = createSettings({ + codexSystemDefaultRealHomeEnabled: true, codexManagedAccounts: [ { id: 'account-1', @@ -385,7 +386,8 @@ describe('CodexAccountService config sync', () => { for (const key of fixture.orcaKeys) { expect(entries.has(key)).toBe(false) } - expect(entries.has(fixture.userKey)).toBe(true) + // The launch-time hook mirror remaps user trust to this home's hooks.json. + expect(entries.has(fixture.userKey)).toBe(false) } expectSanitizedManagedConfig() @@ -727,7 +729,7 @@ describe('CodexAccountService config sync', () => { expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) - it('does not seed Orca-owned source-home trust when adding an account', async () => { + it('does not seed source-home hook trust when adding a self-contained account', async () => { vi.resetModules() let fixture: Awaited> let readHookTrustEntries: typeof ReadHookTrustEntries @@ -748,7 +750,7 @@ describe('CodexAccountService config sync', () => { for (const key of fixture.orcaKeys) { expect(entries.has(key)).toBe(false) } - expect(entries.has(fixture.userKey)).toBe(true) + expect(entries.has(fixture.userKey)).toBe(false) writeFileSync( join(loginHome!, 'auth.json'), createCodexAuthJson('user@example.com', 'provider-account-1', 'refresh-token'), @@ -771,7 +773,7 @@ describe('CodexAccountService config sync', () => { readHookTrustEntries = (await import('../codex/config-toml-trust')).readHookTrustEntries writeFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), fixture.config, 'utf-8') - const store = createStore(createSettings()) + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) const rateLimits = createRateLimits() const runtimeHome = createRuntimeHome() const { CodexAccountService } = await import('./service') diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 90ab4f3d4bc..fc892f8abd6 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -4,7 +4,7 @@ main-process module so the managed-account boundary stays explicit. */ import { randomUUID } from 'node:crypto' import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join, relative, resolve, sep } from 'node:path' +import { dirname, join, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' import { getSpawnArgsForWindows } from '../win32-utils' @@ -21,7 +21,6 @@ import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-fl import { getCodexManagedHookInstallMaterial } from '../codex/hook-service' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' import { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' @@ -44,6 +43,7 @@ import { setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' +import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -727,47 +727,11 @@ export class CodexAccountService { return candidatePath } - const rootPath = this.getManagedAccountsRoot() - const resolvedCandidate = resolve(candidatePath) - const resolvedRoot = resolve(rootPath) - - if (!existsSync(resolvedCandidate)) { - throw new Error('Managed Codex home directory does not exist on disk.') - } - - // realpath() requires the leaf to exist. For pre-login add flow we create - // the home directory first so the containment check still verifies the - // canonical on-disk target rather than trusting persisted text blindly. - const canonicalCandidate = realpathSync(resolvedCandidate) - const canonicalRoot = realpathSync(resolvedRoot) - - // Why: the prefix check must compare canonical paths on both sides. On - // macOS, userData sits under /var/folders/... which realpath resolves to - // /private/var/folders/...; comparing a canonical candidate against a - // non-canonical root would spuriously reject every managed home. In dev - // mode (orca-dev/ vs orca/) this check also filters out production-rooted - // paths before downstream sync runs. - if ( - canonicalCandidate !== canonicalRoot && - !canonicalCandidate.startsWith(canonicalRoot + sep) - ) { - throw new Error( - `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).` - ) - } - const relativePath = relative(canonicalRoot, canonicalCandidate) - const escaped = - relativePath === '' || relativePath.startsWith('..') || relativePath.includes(`..${sep}`) - - if (escaped) { - throw new Error('Managed Codex home escaped Orca account storage.') - } - - if (!existsSync(join(canonicalCandidate, '.orca-managed-home'))) { - throw new Error('Managed Codex home is missing Orca ownership marker.') - } - - return canonicalCandidate + return assertOwnedHostCodexManagedHomePath({ + candidatePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath() + }) } private safeRemoveWslManagedHomeCandidate(