diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index af4d5651ea05..c0a7adee1007 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -148,6 +148,7 @@ "../src/main/codex/codex-hook-command-form.ts", "../src/main/codex/codex-hook-definition.ts", "../src/main/codex/codex-managed-home-resource-copy-marker.ts", + "../src/main/codex/codex-managed-home-rules.ts", "../src/main/codex/codex-managed-trust-grant-plan.ts", "../src/main/codex/codex-path-observation.ts", "../src/main/codex/codex-hook-identity.ts", diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index bbadd1091126..db3f4d011981 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -258,6 +259,30 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => { expect(existsSync(join(systemHome(), 'sessions', siblingRollout))).toBe(false) }) + it('stops a real account launch when global rules cannot safely coexist with account files', async () => { + const importName = `orca-global-0000000000-${createHash('sha256').update('default.rules').digest('hex').slice(0, 32)}.rules` + const account = createManagedAccount( + 'account-1', + 'acct-1', + createAuth('one@example.com', 'acct-1', 'fresh', 10_000) + ) + mkdirSync(join(systemHome(), 'rules')) + writeFileSync(join(systemHome(), 'rules', 'default.rules'), 'global forbidden policy') + mkdirSync(join(account.managedHomePath, 'rules')) + writeFileSync(join(account.managedHomePath, 'rules', importName), 'user-owned collision') + const { store } = createStore([account], account.id) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture provides the only store methods account launch uses. + const service = new CodexRuntimeHomeService(store as never) + expect(() => service.prepareForCodexLaunch()).toThrow('launch stopped') + expect(readFileSync(join(account.managedHomePath, 'rules', importName), 'utf8')).toBe( + 'user-owned collision' + ) + expect(readFileSync(join(systemHome(), 'rules', 'default.rules'), 'utf8')).toBe( + 'global forbidden policy' + ) + }) + it('does not expose an untrusted persisted home through rollout discovery', async () => { const outsideHome = join(testState.userData, 'outside', 'account-1', 'home') mkdirSync(join(outsideHome, 'sessions'), { recursive: true }) diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index e838d11fe6b2..a5e585ebbd28 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -19,6 +19,8 @@ import { } from './codex-managed-home-resource-copy-marker' import { observe, observeResolvedPathEntry } from './codex-path-observation' +import { syncSystemCodexRules } from './codex-managed-home-rules' + const CODEX_GLOBAL_INSTRUCTIONS_ENTRY = 'AGENTS.md' const CODEX_SYSTEM_RESOURCE_ENTRIES = [ @@ -81,6 +83,7 @@ export function getOrcaUserDataPath(): string { export function syncSystemCodexResourcesIntoManagedHome(managedHomePath?: string): void { const targetHome = managedHomePath ?? getOrcaManagedCodexHomePath() const systemHomePath = getSystemCodexHomePath() + syncSystemCodexRules(systemHomePath, targetHome) for (const entryName of CODEX_SYSTEM_RESOURCE_ENTRIES) { linkSystemCodexResource(systemHomePath, targetHome, entryName) } diff --git a/src/main/codex/codex-managed-home-rules.test.ts b/src/main/codex/codex-managed-home-rules.test.ts new file mode 100644 index 000000000000..2030cd1e04b4 --- /dev/null +++ b/src/main/codex/codex-managed-home-rules.test.ts @@ -0,0 +1,296 @@ +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { createHash } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { faults } = vi.hoisted(() => ({ + faults: { readPath: '', linkPath: '', mutatePath: '', reads: 0 } +})) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readFileSync: (...args: Parameters) => { + if (args[0] === faults.readPath) { + throw new Error('policy read denied') + } + if (args[0] === faults.mutatePath && ++faults.reads === 2) { + actual.writeFileSync(faults.mutatePath, 'changed') + } + return actual.readFileSync(...args) + }, + linkSync: (...args: Parameters) => { + if (args[1] === faults.linkPath) { + throw new Error('publication interrupted') + } + return actual.linkSync(...args) + } + } +}) + +const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() })) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: homedirMock +})) +import { syncSystemCodexResourcesIntoManagedHome } from './codex-home-paths' + +let root: string +let system: string +let first: string +let second: string +function files(home: string): string[] { + return readdirSync(join(home, 'rules')) + .filter((name) => name.endsWith('.rules')) + .sort() +} +function contents(home: string): string { + return files(home) + .map((name) => readFileSync(join(home, 'rules', name), 'utf8')) + .join('\n') +} +function imported(name: string, index = 0): string { + return `orca-global-${String(index).padStart(10, '0')}-${createHash('sha256').update(name).digest('hex').slice(0, 32)}.rules` +} +beforeEach(() => { + faults.readPath = '' + faults.linkPath = '' + faults.mutatePath = '' + faults.reads = 0 + root = mkdtempSync(join(tmpdir(), 'orca-rules-')) + homedirMock.mockReturnValue(root) + system = join(root, '.codex') + first = join(root, 'first') + second = join(root, 'second') + for (const home of [system, first, second]) { + mkdirSync(join(home, 'rules'), { recursive: true }) + } +}) +afterEach(() => { + faults.readPath = '' + faults.linkPath = '' + faults.mutatePath = '' + rmSync(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('managed Codex rule scope', () => { + it('loads global rules alongside an existing account rules directory', () => { + writeFileSync( + join(system, 'rules', 'default.rules'), + 'prefix_rule(pattern=["global"], decision="forbidden")\n' + ) + writeFileSync( + join(first, 'rules', 'default.rules'), + 'prefix_rule(pattern=["account"], decision="allow")\n' + ) + syncSystemCodexResourcesIntoManagedHome(first) + expect(contents(first)).toContain('decision="forbidden"') + expect(readFileSync(join(first, 'rules', 'default.rules'), 'utf8')).toContain('["account"]') + }) + it('keeps newly saved approvals in the selected account', () => { + writeFileSync( + join(system, 'rules', 'default.rules'), + 'prefix_rule(pattern=["global"], decision="prompt")\n' + ) + syncSystemCodexResourcesIntoManagedHome(first) + syncSystemCodexResourcesIntoManagedHome(second) + writeFileSync( + join(first, 'rules', 'default.rules'), + 'prefix_rule(pattern=["first-only"], decision="allow")\n' + ) + expect(contents(first)).toContain('["global"]') + expect(contents(second)).not.toContain('["first-only"]') + expect(contents(system)).not.toContain('["first-only"]') + expect(existsSync(join(second, 'rules', 'default.rules'))).toBe(false) + }) +}) + +it('refreshes changed globals and removes deleted imports without touching account rules', () => { + const global = join(system, 'rules', 'old.rules') + writeFileSync(global, 'old') + writeFileSync(join(first, 'rules', 'default.rules'), 'account') + syncSystemCodexResourcesIntoManagedHome(first) + writeFileSync(global, 'new') + syncSystemCodexResourcesIntoManagedHome(first) + expect(contents(first)).not.toContain('old') + expect(contents(first)).toContain('new') + rmSync(global) + writeFileSync(join(system, 'rules', 'renamed.rules'), 'renamed') + syncSystemCodexResourcesIntoManagedHome(first) + expect(files(first).sort()).toEqual(['default.rules', imported('renamed.rules')]) + rmSync(join(system, 'rules'), { recursive: true }) + syncSystemCodexResourcesIntoManagedHome(first) + expect(contents(first)).toBe('account') +}) + +it('refuses a colliding user file or a changed imported file without overwriting it', () => { + writeFileSync(join(system, 'rules', 'default.rules'), 'global') + const importedPath = join(first, 'rules', imported('default.rules')) + writeFileSync(importedPath, 'user') + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('launch stopped') + expect(readFileSync(importedPath, 'utf8')).toBe('user') + rmSync(importedPath) + syncSystemCodexResourcesIntoManagedHome(first) + writeFileSync(importedPath, 'edited') + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('launch stopped') + expect(readFileSync(importedPath, 'utf8')).toBe('edited') +}) + +it.skipIf(process.platform === 'win32')( + 'refuses destination symlinks without changing the system or another home', + () => { + writeFileSync(join(system, 'rules', 'default.rules'), 'global') + rmSync(join(first, 'rules'), { recursive: true }) + symlinkSync(join(system, 'rules'), join(first, 'rules')) + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('launch stopped') + expect(files(system)).toEqual(['default.rules']) + rmSync(join(first, 'rules')) + mkdirSync(join(first, 'rules')) + symlinkSync( + join(system, 'rules', 'default.rules'), + join(first, 'rules', imported('default.rules')) + ) + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('launch stopped') + expect(contents(system)).toBe('global') + } +) + +it('refuses unreadable or changing policy rather than removing the previous restrictions', () => { + const global = join(system, 'rules', 'default.rules') + writeFileSync(global, 'global') + syncSystemCodexResourcesIntoManagedHome(first) + faults.readPath = global + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('policy read denied') + faults.readPath = '' + expect(contents(first)).toBe('global') + faults.mutatePath = global + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('launch stopped') + faults.mutatePath = '' + expect(contents(first)).toBe('global') +}) + +it('recovers a partially published policy using only journaled expected contents', () => { + writeFileSync(join(system, 'rules', 'a.rules'), 'first') + writeFileSync(join(system, 'rules', 'b.rules'), 'second') + faults.linkPath = join(first, 'rules', imported('b.rules', 1)) + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('publication interrupted') + expect(contents(first)).toBe('first') + faults.linkPath = '' + syncSystemCodexResourcesIntoManagedHome(first) + expect(contents(first)).toBe('first\nsecond') +}) + +it('refuses corrupt ownership metadata and malformed source directories', () => { + writeFileSync(join(system, 'rules', 'default.rules'), 'global') + writeFileSync(join(first, '.orca-global-rule-imports.json'), '{}') + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('launch stopped') + expect(files(first)).toEqual([]) + rmSync(join(first, '.orca-global-rule-imports.json')) + rmSync(join(system, 'rules'), { recursive: true }) + writeFileSync(join(system, 'rules'), 'not a directory') + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('launch stopped') +}) + +it.skipIf(!process.env.ORCA_REAL_CODEX_POLICY_TEST)( + 'preserves shipping Codex forbidden/prompt precedence over account allowances', + async () => { + const { runProcess } = await import('../../shared/child-process/run-process') + writeFileSync( + join(system, 'rules', 'default.rules'), + '# Global module\nprefix = ["policy-probe", "blocked"]\nprefix_rule(pattern=prefix, decision="forbidden")\n' + ) + writeFileSync( + join(system, 'rules', 'extra.rules'), + 'prefix_rule(pattern=["policy-probe", "asked"], decision="prompt")\nprefix_rule(pattern=["policy-probe", "global-allowed"], decision="allow")\n' + ) + writeFileSync( + join(first, 'rules', 'default.rules'), + '# Account module uses the same variable name independently\nprefix = ["policy-probe"]\nprefix_rule(pattern=prefix, decision="allow")\n' + ) + syncSystemCodexResourcesIntoManagedHome(first) + for (const [argument, expected] of [ + ['blocked', 'forbidden'], + ['asked', 'prompt'], + ['global-allowed', 'allow'] + ]) { + const result = await runProcess({ + program: process.env.ORCA_REAL_CODEX_POLICY_TEST ?? 'codex', + args: [ + 'execpolicy', + 'check', + ...files(first).flatMap((name) => ['--rules', join(first, 'rules', name)]), + '--', + 'policy-probe', + argument + ], + env: { ...process.env, CODEX_HOME: first }, + timeoutMs: 10_000 + }) + expect(result.code, result.stderr).toBe(0) + const parsed: unknown = JSON.parse(result.stdout) + expect(parsed).toMatchObject({ decision: expected }) + } + } +) + +it('recovers the interrupted guarded rename window for both imports and the ownership journal', () => { + writeFileSync(join(system, 'rules', 'default.rules'), 'before') + syncSystemCodexResourcesIntoManagedHome(first) + const rulePath = join(first, 'rules', imported('default.rules')) + const manifestPath = join(first, '.orca-global-rule-imports.json') + renameSync(rulePath, `${rulePath}.orca-guarded`) + renameSync(manifestPath, `${manifestPath}.orca-guarded`) + writeFileSync(join(system, 'rules', 'default.rules'), 'after') + syncSystemCodexResourcesIntoManagedHome(first) + expect(contents(first)).toBe('after') + expect(existsSync(`${rulePath}.orca-guarded`)).toBe(false) + expect(existsSync(`${manifestPath}.orca-guarded`)).toBe(false) +}) + +it('imports a valid near-limit global filename using a bounded target filename', () => { + const name = `${'x'.repeat(245)}.rules` + writeFileSync(join(system, 'rules', name), 'global') + syncSystemCodexResourcesIntoManagedHome(first) + expect(contents(first)).toBe('global') + expect(files(first)[0].length).toBeLessThan(100) +}) + +it.skipIf(process.platform === 'win32')( + 'matches Codex discovery by ignoring symlink and directory rule entries', + () => { + writeFileSync(join(system, 'rules', 'default.rules'), 'regular') + mkdirSync(join(system, 'rules', 'directory.rules')) + writeFileSync(join(root, 'ignored'), 'unintended allowance') + symlinkSync(join(root, 'ignored'), join(system, 'rules', 'linked.rules')) + syncSystemCodexResourcesIntoManagedHome(first) + expect(contents(first)).toBe('regular') + expect(files(first)).toEqual([imported('default.rules')]) + } +) + +it.skipIf(process.platform === 'win32')( + 'does not follow an unexpected guarded recovery symlink', + () => { + writeFileSync(join(system, 'rules', 'default.rules'), 'global') + syncSystemCodexResourcesIntoManagedHome(first) + const rulePath = join(first, 'rules', imported('default.rules')) + symlinkSync(join(system, 'rules', 'default.rules'), `${rulePath}.orca-guarded`) + expect(() => syncSystemCodexResourcesIntoManagedHome(first)).toThrow('launch stopped') + expect(contents(system)).toBe('global') + expect(contents(first)).toBe('global') + } +) diff --git a/src/main/codex/codex-managed-home-rules.ts b/src/main/codex/codex-managed-home-rules.ts new file mode 100644 index 000000000000..6ad6960fbdbc --- /dev/null +++ b/src/main/codex/codex-managed-home-rules.ts @@ -0,0 +1,221 @@ +import { createHash } from 'node:crypto' +import { lstatSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { + recoverInterruptedGuardedFileOperation, + removeFileAtomicallyIfUnchanged, + writeFileAtomicallyIfUnchanged +} from '../codex-accounts/fs-utils' +import { observe } from './codex-path-observation' + +const IMPORT_PREFIX = 'orca-global-' +const MANIFEST_NAME = '.orca-global-rule-imports.json' +type RuleChange = { sourceName: string; before: string | null; after: string | null } +type RuleManifest = { sourcePath: string; files: Record } + +function fail(): never { + throw new Error( + 'Cannot safely import global Codex rules; launch stopped. Check rule-file permissions and conflicting Orca import files in the managed Codex home, then retry without deleting account rules.' + ) +} + +function readRegularFile(path: string): string | null { + const entry = observe(() => lstatSync(path)) + if (entry.kind === 'absent') { + return null + } + if (entry.kind !== 'present' || !entry.value.isFile()) { + fail() + } + return readFileSync(path, 'utf8') +} + +function ensureOwnedDirectory(path: string): void { + const entry = observe(() => lstatSync(path)) + if (entry.kind === 'absent') { + mkdirSync(path) + } else if ( + entry.kind !== 'present' || + !entry.value.isDirectory() || + entry.value.isSymbolicLink() + ) { + fail() + } +} + +function ruleNames(path: string): string[] { + const directory = observe(() => statSync(path)) + if (directory.kind === 'absent') { + return [] + } + if (directory.kind !== 'present' || !directory.value.isDirectory()) { + fail() + } + const entry = observe(() => readdirSync(path)) + if (entry.kind === 'absent') { + return [] + } + if (entry.kind !== 'present') { + fail() + } + return entry.value + .filter((name) => name.endsWith('.rules') && lstatSync(join(path, name)).isFile()) + .sort() +} + +function snapshotRules(path: string): Record { + const names = ruleNames(path) + const files: Record = {} + for (const [index, name] of names.entries()) { + const source = join(path, name) + if (!lstatSync(source).isFile()) { + fail() + } + files[importName(name, index)] = { sourceName: name, contents: readFileSync(source, 'utf8') } + } + // A changing source is not a coherent permission-policy snapshot. + if (JSON.stringify(ruleNames(path)) !== JSON.stringify(names)) { + fail() + } + for (const [index, name] of names.entries()) { + if (readFileSync(join(path, name), 'utf8') !== files[importName(name, index)].contents) { + fail() + } + } + return files +} + +function importName(sourceName: string, index: number): string { + const digest = createHash('sha256').update(sourceName).digest('hex').slice(0, 32) + return `${IMPORT_PREFIX}${String(index).padStart(10, '0')}-${digest}.rules` +} + +function recoverRegularFile(path: string, validate: (contents: string) => void): void { + const held = readRegularFile(`${path}.orca-guarded`) + if (held === null) { + return + } + validate(held) + const current = readRegularFile(path) + if (current !== null) { + validate(current) + } + recoverInterruptedGuardedFileOperation(path) +} + +function parseManifest(contents: string | null, sourcePath: string): RuleManifest { + if (contents === null) { + return { sourcePath, files: {} } + } + const value: unknown = JSON.parse(contents) + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail() + } + if (!('sourcePath' in value) || value.sourcePath !== sourcePath || !('files' in value)) { + fail() + } + if (!value.files || typeof value.files !== 'object' || Array.isArray(value.files)) { + fail() + } + const files: Record = {} + for (const [name, change] of Object.entries(value.files)) { + if (!name.startsWith(IMPORT_PREFIX) || !name.endsWith('.rules') || /[/\\]/.test(name)) { + fail() + } + if (!change || typeof change !== 'object' || Array.isArray(change)) { + fail() + } + if (!('before' in change) || !('after' in change) || !('sourceName' in change)) { + fail() + } + if (typeof change.sourceName !== 'string' || !change.sourceName.endsWith('.rules')) { + fail() + } + if (change.before !== null && typeof change.before !== 'string') { + fail() + } + if (change.after !== null && typeof change.after !== 'string') { + fail() + } + files[name] = { sourceName: change.sourceName, before: change.before, after: change.after } + } + return { sourcePath, files } +} + +function publishManifest(path: string, previous: string | null, manifest: RuleManifest): string { + const contents = `${JSON.stringify(manifest)}\n` + if ( + previous !== contents && + !writeFileAtomicallyIfUnchanged(path, previous, contents, { mode: 0o600 }) + ) { + fail() + } + return contents +} + +function applyChanges(rulesPath: string, manifest: RuleManifest): RuleManifest { + const settled: RuleManifest = { sourcePath: manifest.sourcePath, files: {} } + for (const [name, change] of Object.entries(manifest.files)) { + const path = join(rulesPath, name) + recoverRegularFile(path, (contents) => { + if (contents !== change.before && contents !== change.after) { + fail() + } + }) + const current = readRegularFile(path) + if (current !== change.after) { + if (current !== change.before) { + fail() + } + if (change.after === null) { + if (current !== null && !removeFileAtomicallyIfUnchanged(path, current)) { + fail() + } + } else if (!writeFileAtomicallyIfUnchanged(path, current, change.after, { mode: 0o600 })) { + fail() + } + } + if (change.after !== null) { + settled.files[name] = { + sourceName: change.sourceName, + before: change.after, + after: change.after + } + } + } + return settled +} + +/** Global reads are imported; Codex's writable default.rules stays account-local. */ +export function syncSystemCodexRules(systemHomePath: string, managedHomePath: string): void { + const sourcePath = join(systemHomePath, 'rules') + const desired = snapshotRules(sourcePath) + const manifestPath = join(managedHomePath, MANIFEST_NAME) + recoverRegularFile(manifestPath, (contents) => { + parseManifest(contents, sourcePath) + }) + let previous = readRegularFile(manifestPath) + const existing = parseManifest(previous, sourcePath) + if (Object.keys(desired).length === 0 && Object.keys(existing.files).length === 0) { + return + } + const rulesPath = join(managedHomePath, 'rules') + ensureOwnedDirectory(rulesPath) + // A persisted pending change makes interrupted publication recoverable without claiming user files. + const settled = applyChanges(rulesPath, existing) + previous = publishManifest(manifestPath, previous, settled) + const pending: RuleManifest = { sourcePath, files: {} } + for (const name of new Set([...Object.keys(settled.files), ...Object.keys(desired)])) { + const before = settled.files[name]?.after ?? null + if (readRegularFile(join(rulesPath, name)) !== before) { + fail() + } + pending.files[name] = { + sourceName: desired[name]?.sourceName ?? settled.files[name].sourceName, + before, + after: desired[name]?.contents ?? null + } + } + previous = publishManifest(manifestPath, previous, pending) + publishManifest(manifestPath, previous, applyChanges(rulesPath, pending)) +}