From 489066eb7dfa2eb2b2339439bab3a3da816c5929 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 01:49:26 -0700 Subject: [PATCH 01/44] Add D1 invocation contract and independent task clones --- agents/contract.ts | 82 +++++++++++++++++++++++++++++++++++ git/clone.ts | 78 ++++++++++++++++++++++++++++++++++ test/agent-clone.test.ts | 85 +++++++++++++++++++++++++++++++++++++ test/agent-contract.test.ts | 42 ++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 agents/contract.ts create mode 100644 git/clone.ts create mode 100644 test/agent-clone.test.ts create mode 100644 test/agent-contract.test.ts diff --git a/agents/contract.ts b/agents/contract.ts new file mode 100644 index 0000000..89f9a9c --- /dev/null +++ b/agents/contract.ts @@ -0,0 +1,82 @@ +/** Lane D/F boundary. Only the runner may construct requests after admission. */ +export interface TaskClone { + readonly id: string; + readonly taskId: string; + /** Staging clone, NOT a container-ready mount. D2 must allocate bounded storage. */ + readonly directory: string; + readonly head: string; +} + +export type Phase = 'planning' | 'questions' | 'review' | 'execute' | 'fix'; +export interface InvocationContext { + readonly snapshotId: string; + readonly planId: string; + readonly planRevision: number; + readonly assignmentId: string; + readonly referencedCodeHash: string; + readonly stateVersion: number; +} +export interface InvocationInput { + readonly clone: TaskClone; + readonly phase: Phase; + readonly vendor: 'claude' | 'codex'; + readonly approvedArgv: readonly (readonly string[])[]; + readonly deadline: number; + readonly attemptId: string; + readonly context: InvocationContext; +} +export type StopReason = 'cancelled' | 'timeout' | 'shutdown' | 'output-limit' | 'capture-failure'; +export interface InvocationResult { + readonly attemptId: string; + readonly context: InvocationContext; + readonly exitCode: number | null; + readonly signal: string | null; + readonly stopReason?: StopReason; + readonly stdout: string; + readonly stderr: string; +} +/** + * F owns persisted pending/stale and admission; D owns running invocations. + * cancel() records the first reason and requests termination, never settlement. + * settled resolves only after the container AND capture processes terminate. + * completed/failed/cancelled records are published by F using attemptId + context + * CAS; discarded stale output still must settle before releasing D's slot. + * Closing rejects admission before draining requests, cancelling, and awaiting + * settlement. No retry may replace an active invocation, even after lease expiry. + */ +export interface InvocationHandle { + readonly attemptId: string; + readonly settled: Promise; + cancel(reason: StopReason): void; +} + +const nonempty = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && !value.includes('\0'); +const integer = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; + +/** Capture a deep immutable request so caller edits cannot change an active run. */ +export function captureInvocation(input: InvocationInput, now = Date.now()): InvocationInput { + if (!input || !input.clone || !input.context) throw new Error('Missing invocation context.'); + if (!['planning', 'questions', 'review', 'execute', 'fix'].includes(input.phase) + || !['claude', 'codex'].includes(input.vendor)) throw new Error('Unsupported invocation profile.'); + if (!nonempty(input.attemptId) || !nonempty(input.clone.id) || !nonempty(input.clone.taskId) + || !nonempty(input.clone.directory) || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(input.clone.head)) + throw new Error('Invalid task clone or attempt identity.'); + if (!Number.isFinite(now) || !Number.isSafeInteger(input.deadline) || input.deadline <= now) + throw new Error('Invocation requires a finite future deadline.'); + const context = input.context; + if (![context.snapshotId, context.planId, context.assignmentId, context.referencedCodeHash].every(nonempty) + || !integer(context.planRevision) || !integer(context.stateVersion)) throw new Error('Invalid captured context.'); + if (!Array.isArray(input.approvedArgv) || input.approvedArgv.some(argv => !Array.isArray(argv) + || argv.length === 0 || !nonempty(argv[0]) || argv.some(arg => typeof arg !== 'string' || arg.includes('\0')))) + throw new Error('Commands must be complete literal argv arrays.'); + if (['planning', 'questions'].includes(input.phase) && input.approvedArgv.length) + throw new Error('Read-only authoring and questions cannot execute commands.'); + return Object.freeze({ ...input, clone: Object.freeze({ ...input.clone }), context: Object.freeze({ ...context }), + approvedArgv: Object.freeze(input.approvedArgv.map(argv => Object.freeze([...argv]))) }); +} + +/** Dispatcher predicate, not a sandbox. An adapter must enforce this externally. */ +export function permitsCommand(input: InvocationInput, argv: readonly string[]): boolean { + return !['planning', 'questions'].includes(input.phase) && input.approvedArgv.some(approved => + approved.length === argv.length && approved.every((arg, index) => arg === argv[index])); +} diff --git a/git/clone.ts b/git/clone.ts new file mode 100644 index 0000000..18dd75f --- /dev/null +++ b/git/clone.ts @@ -0,0 +1,78 @@ +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import type { TaskClone } from '../agents/contract.ts'; + +/** + * Prepare an independent committed snapshot. This is trusted staging, not the + * writable execution filesystem: D2 must reserve bounded storage and separate + * metadata before mounting it. Source must stay quiescent during this operation. + * No hooks, filters from user config, credentials, submodules or network access. + */ +export function createTaskClone(options: { + source: string; parent: string; taskId: string; head: string; timeoutMs?: number; +}): TaskClone { + if (!options.taskId || options.taskId.includes('\0')) throw new Error('Task identity is required.'); + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(options.head)) throw new Error('A full committed head is required.'); + const timeout = options.timeoutMs ?? 30_000; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 120_000) throw new Error('Invalid clone deadline.'); + const deadline = performance.now() + timeout; + const remaining = () => { + const value = Math.ceil(deadline - performance.now()); + if (value <= 0) throw new Error('Clone deadline exceeded.'); + return value; + }; + const source = realpathSync(options.source), parent = realpathSync(options.parent); + const within = (base: string, path: string) => { + const rel = relative(base, path); + return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../')); + }; + if (within(source, parent)) throw new Error('Task storage must be outside the source repository.'); + // Deliberately do not inherit Git variables or credential/config environment. + const env = { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', GIT_GRAFT_FILE: '/dev/null' }; + const run = (cwd: string, ...args: string[]) => execFileSync('git', [ + '--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=', + '-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', ...args, + ], { cwd, env, timeout: remaining(), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim(); + const common = resolve(source, run(source, 'rev-parse', '--git-common-dir')); + if (within(common, parent)) throw new Error('Task storage must be outside source metadata.'); + function audit(metadata: string, independent: boolean) { + for (const name of ['shallow', 'info/grafts', 'objects/info/alternates', 'objects/info/http-alternates']) { + if (lstatSync(join(metadata, name), { throwIfNoEntry: false })) throw new Error(`Unsupported Git storage: ${name}`); + } + const pending = [join(metadata, 'objects')]; + let count = 0; + while (pending.length) { + remaining(); + if (++count > 100_000) throw new Error('Object storage exceeds inspection limit.'); + const path = pending.pop()!, stat = lstatSync(path); + if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error('Unsupported object entry.'); + if (independent && stat.isFile() && stat.nlink !== 1) throw new Error('Task objects must not be hard-linked.'); + if (stat.isDirectory()) { + const children = readdirSync(path); + if (count + pending.length + children.length > 100_000) throw new Error('Object storage exceeds inspection limit.'); + pending.push(...children.map(child => join(path, child))); + } + } + } + audit(common, false); + if (run(source, 'for-each-ref', '--format=%(refname)', 'refs/replace')) throw new Error('Replacement objects are unsupported.'); + if (run(source, 'rev-parse', '--verify', `${options.head}^{commit}`) !== options.head) throw new Error('Head is not a commit.'); + const directory = mkdtempSync(join(parent, 'codeboost-task-')); + try { + run(parent, '-c', 'protocol.file.allow=always', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, directory); + const metadata = join(directory, '.git'); + if (!lstatSync(metadata).isDirectory()) throw new Error('Task requires standalone Git metadata.'); + audit(metadata, true); + run(directory, 'remote', 'remove', 'origin'); + run(directory, 'checkout', '--detach', options.head); + if (run(directory, 'rev-parse', 'HEAD') !== options.head) throw new Error('Task head changed during clone.'); + return Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head }); + } catch (error) { + rmSync(directory, { recursive: true, force: true }); + throw error; + } +} diff --git a/test/agent-clone.test.ts b/test/agent-clone.test.ts new file mode 100644 index 0000000..9e4fd86 --- /dev/null +++ b/test/agent-clone.test.ts @@ -0,0 +1,85 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createTaskClone } from '../git/clone.ts'; + +const roots: string[] = []; +const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], + { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'clone-test-')); roots.push(root); + const source = join(root, 'source'), parent = join(root, 'tasks'); + mkdirSync(source); mkdirSync(parent); + git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com'); + writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline'); + return { source, parent, head: git(source, 'rev-parse', 'HEAD'), taskId: 'task-1' }; +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +describe('isolated staging clone', () => { + it('copies objects, ignores dirty source changes, and has no origin or shared metadata', () => { + const input = fixture(); + writeFileSync(join(input.source, 'file.txt'), 'uncommitted'); + const clone = createTaskClone(input); + expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n'); + expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true); + expect(git(clone.directory, 'status', '--porcelain')).toBe(''); + expect(git(clone.directory, 'remote')).toBe(''); + const hash = git(input.source, 'rev-parse', 'HEAD:file.txt'); + const path = join('objects', hash.slice(0, 2), hash.slice(2)); + const sourceObject = join(input.source, '.git', path), cloneObject = join(clone.directory, '.git', path); + const before = readFileSync(sourceObject); + expect(lstatSync(cloneObject).nlink).toBe(1); + expect(lstatSync(cloneObject).ino).not.toBe(lstatSync(sourceObject).ino); + writeFileSync(cloneObject, 'corrupted disposable task object'); + expect(readFileSync(sourceObject)).toEqual(before); + expect(git(input.source, 'cat-file', '-p', hash)).toBe('trusted'); + }); + it('supports linked-worktree sources but produces standalone metadata', () => { + const input = fixture(), linked = join(input.parent, 'linked'); + git(input.source, 'worktree', 'add', '--detach', linked, input.head); + const clone = createTaskClone({ ...input, source: linked }); + expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true); + expect(git(clone.directory, 'rev-parse', 'HEAD')).toBe(input.head); + }); + it.each(['objects/info/alternates', 'objects/info/http-alternates', 'info/grafts', 'shallow'])('rejects %s before allocating a clone', name => { + const input = fixture(); + writeFileSync(join(input.source, '.git', name), ''); + expect(() => createTaskClone(input)).toThrow('Unsupported Git storage'); + expect(readdirSync(input.parent)).toEqual([]); + }); + it('rejects object symlinks and replacements', () => { + const input = fixture(); + symlinkSync('/tmp', join(input.source, '.git/objects/linked')); + expect(() => createTaskClone(input)).toThrow('object entry'); + rmSync(join(input.source, '.git/objects/linked')); + git(input.source, 'update-ref', `refs/replace/${input.head}`, input.head); + expect(() => createTaskClone(input)).toThrow('Replacement'); + }); + it('rejects nested storage, including paths through symlinks', () => { + const input = fixture(), nested = join(input.source, 'tasks'), alias = join(input.parent, 'alias'); + mkdirSync(nested); symlinkSync(nested, alias); + expect(() => createTaskClone({ ...input, parent: alias })).toThrow('outside'); + }); + it('requires a full existing commit and finite time budget', () => { + const input = fixture(); + expect(() => createTaskClone({ ...input, head: 'HEAD' })).toThrow('full committed'); + expect(() => createTaskClone({ ...input, head: 'f'.repeat(40) })).toThrow(); + expect(() => createTaskClone({ ...input, timeoutMs: Infinity })).toThrow('deadline'); + expect(readdirSync(input.parent)).toEqual([]); + }); + it('does not inherit Git directory, index, configuration or object overrides', () => { + const input = fixture(); + const keys = ['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0']; + const old = keys.map(key => process.env[key]); + try { + Object.assign(process.env, { GIT_DIR: '/missing', GIT_INDEX_FILE: '/missing', GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.bare', GIT_CONFIG_VALUE_0: 'true' }); + const clone = createTaskClone(input); + expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n'); + } finally { + keys.forEach((key, i) => { if (old[i] === undefined) delete process.env[key]; else process.env[key] = old[i]; }); + } + }); +}); diff --git a/test/agent-contract.test.ts b/test/agent-contract.test.ts new file mode 100644 index 0000000..04e96ce --- /dev/null +++ b/test/agent-contract.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { captureInvocation, permitsCommand, type InvocationInput } from '../agents/contract.ts'; + +const request = (): InvocationInput => ({ + clone: { id: 'clone-1', taskId: 'task-1', directory: '/tasks/one', head: 'a'.repeat(40) }, + vendor: 'codex', phase: 'review', approvedArgv: [['npm', 'test']], deadline: 2000, attemptId: 'attempt-1', + context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', + referencedCodeHash: 'hash-1', stateVersion: 3 }, +}); +describe('invocation boundary', () => { + it('captures identity, context and exact argv independently of mutable caller state', () => { + const original = request(); + const captured = captureInvocation(original, 1000); + (original.approvedArgv[0] as string[]).push('--changed'); + (original.context as { stateVersion: number }).stateVersion = 4; + expect(captured.context.stateVersion).toBe(3); + expect(captured.approvedArgv).toEqual([['npm', 'test']]); + expect(Object.isFrozen(captured.clone)).toBe(true); + expect(Object.isFrozen(captured.context)).toBe(true); + expect(Object.isFrozen(captured.approvedArgv[0])).toBe(true); + expect(permitsCommand(captured, ['npm', 'test'])).toBe(true); + expect(permitsCommand(captured, ['npm', 'test', '--changed'])).toBe(false); + expect(permitsCommand(captured, ['npm'])).toBe(false); + expect(permitsCommand(captured, ['sh', '-c', 'npm test'])).toBe(false); + }); + it.each(['planning', 'questions'] as const)('%s cannot acquire command permission', phase => { + expect(() => captureInvocation({ ...request(), phase }, 1000)).toThrow('cannot execute'); + const input = captureInvocation({ ...request(), phase, approvedArgv: [] }, 1000); + expect(permitsCommand(input, ['npm', 'test'])).toBe(false); + }); + it.each([NaN, Infinity, -1, 999, 1000, 1000.1])('rejects invalid deadline %s', deadline => { + expect(() => captureInvocation({ ...request(), deadline }, 1000)).toThrow('deadline'); + }); + it.each([[], [''], ['npm', '\0'], 'npm test'])('rejects malformed argv %j', argv => { + expect(() => captureInvocation({ ...request(), approvedArgv: [argv] } as InvocationInput, 1000)).toThrow('argv'); + }); + it('rejects missing context and unsupported profiles', () => { + expect(() => captureInvocation({ ...request(), context: { ...request().context, stateVersion: -1 } }, 1000)).toThrow('context'); + expect(() => captureInvocation({ ...request(), phase: 'shell' } as unknown as InvocationInput, 1000)).toThrow('profile'); + expect(() => captureInvocation({ ...request(), attemptId: '' }, 1000)).toThrow('identity'); + }); +}); From d110d81894a91567edd4a157b968ef5782d5c7b1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 01:57:23 -0700 Subject: [PATCH 02/44] Harden clone containment traversal and deadline validation --- agents/contract.ts | 4 +-- git/clone.ts | 29 ++++++++++++++------- test/agent-clone.test.ts | 52 ++++++++++++++++++++++++++++++++++--- test/agent-contract.test.ts | 6 +++++ 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/agents/contract.ts b/agents/contract.ts index 89f9a9c..dbf6731 100644 --- a/agents/contract.ts +++ b/agents/contract.ts @@ -66,8 +66,8 @@ export function captureInvocation(input: InvocationInput, now = Date.now()): Inv const context = input.context; if (![context.snapshotId, context.planId, context.assignmentId, context.referencedCodeHash].every(nonempty) || !integer(context.planRevision) || !integer(context.stateVersion)) throw new Error('Invalid captured context.'); - if (!Array.isArray(input.approvedArgv) || input.approvedArgv.some(argv => !Array.isArray(argv) - || argv.length === 0 || !nonempty(argv[0]) || argv.some(arg => typeof arg !== 'string' || arg.includes('\0')))) + if (!Array.isArray(input.approvedArgv) || Array.from(input.approvedArgv).some(argv => !Array.isArray(argv) + || argv.length === 0 || !nonempty(argv[0]) || Array.from(argv).some(arg => typeof arg !== 'string' || arg.includes('\0')))) throw new Error('Commands must be complete literal argv arrays.'); if (['planning', 'questions'].includes(input.phase) && input.approvedArgv.length) throw new Error('Read-only authoring and questions cannot execute commands.'); diff --git a/git/clone.ts b/git/clone.ts index 18dd75f..cc9a0ca 100644 --- a/git/clone.ts +++ b/git/clone.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs'; +import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; import type { TaskClone } from '../agents/contract.ts'; @@ -32,12 +32,16 @@ export function createTaskClone(options: { // Deliberately do not inherit Git variables or credential/config environment. const env = { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', GIT_GRAFT_FILE: '/dev/null' }; - const run = (cwd: string, ...args: string[]) => execFileSync('git', [ - '--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=', - '-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', ...args, - ], { cwd, env, timeout: remaining(), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, - stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim(); - const common = resolve(source, run(source, 'rev-parse', '--git-common-dir')); + const run = (cwd: string, ...args: string[]) => { + const result = execFileSync('git', [ + '--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=', + '-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', ...args, + ], { cwd, env, timeout: remaining(), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'] }); + remaining(); + return result.toString().trim(); + }; + const common = realpathSync(resolve(source, run(source, 'rev-parse', '--git-common-dir'))); if (within(common, parent)) throw new Error('Task storage must be outside source metadata.'); function audit(metadata: string, independent: boolean) { for (const name of ['shallow', 'info/grafts', 'objects/info/alternates', 'objects/info/http-alternates']) { @@ -52,9 +56,14 @@ export function createTaskClone(options: { if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error('Unsupported object entry.'); if (independent && stat.isFile() && stat.nlink !== 1) throw new Error('Task objects must not be hard-linked.'); if (stat.isDirectory()) { - const children = readdirSync(path); - if (count + pending.length + children.length > 100_000) throw new Error('Object storage exceeds inspection limit.'); - pending.push(...children.map(child => join(path, child))); + const directory = opendirSync(path, { bufferSize: 1 }); + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + remaining(); + if (count + pending.length >= 100_000) throw new Error('Object storage exceeds inspection limit.'); + pending.push(join(path, entry.name)); + } + } finally { directory.closeSync(); } } } } diff --git a/test/agent-clone.test.ts b/test/agent-clone.test.ts index 9e4fd86..0a71a29 100644 --- a/test/agent-clone.test.ts +++ b/test/agent-clone.test.ts @@ -1,10 +1,19 @@ import { execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync, renameSync, opendirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createTaskClone } from '../git/clone.ts'; +vi.mock('node:fs', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, readdirSync: vi.fn(actual.readdirSync), opendirSync: vi.fn(actual.opendirSync) }; +}); +vi.mock('node:child_process', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, execFileSync: vi.fn(actual.execFileSync) }; +}); + const roots: string[] = []; const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); @@ -16,7 +25,7 @@ function fixture() { writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline'); return { source, parent, head: git(source, 'rev-parse', 'HEAD'), taskId: 'task-1' }; } -afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +afterEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); describe('isolated staging clone', () => { it('copies objects, ignores dirty source changes, and has no origin or shared metadata', () => { const input = fixture(); @@ -69,6 +78,43 @@ describe('isolated staging clone', () => { expect(() => createTaskClone({ ...input, timeoutMs: Infinity })).toThrow('deadline'); expect(readdirSync(input.parent)).toEqual([]); }); + it('canonicalizes symlinked common metadata before checking storage containment', () => { + const input = fixture(), metadata = join(input.parent, 'metadata'); + renameSync(join(input.source, '.git'), metadata); + symlinkSync(metadata, join(input.source, '.git')); + const parent = join(metadata, 'tasks'); mkdirSync(parent); + // Disputed intermediate state: Git reports a lexical path through the link. + expect(git(input.source, 'rev-parse', '--git-common-dir')).toBe('.git'); + expect(lstatSync(join(input.source, '.git')).isSymbolicLink()).toBe(true); + expect(() => createTaskClone({ ...input, parent })).toThrow('outside source metadata'); + expect(readdirSync(parent)).toEqual([]); + }); + it('never materializes an entire object directory before checking its entry budget', () => { + const input = fixture(); + // A bulk enumeration is forbidden even for a small fixture; this asserts + // the disputed intermediate representation, not only a later limit error. + vi.mocked(readdirSync).mockClear(); + createTaskClone(input); + expect(readdirSync).not.toHaveBeenCalled(); + expect(opendirSync).toHaveBeenCalled(); + }); + it('rejects a successful final Git call that returns after the overall deadline', async () => { + const input = fixture(); + const actual = await vi.importActual('node:child_process'); + let elapsed = 0, lateResult = false; + vi.spyOn(performance, 'now').mockImplementation(() => elapsed); + vi.mocked(execFileSync).mockImplementation(((file: string, args: string[], options: object) => { + const result = actual.execFileSync(file, args, options); + if (args.at(-2) === 'rev-parse' && args.at(-1) === 'HEAD') { + elapsed = 1001; lateResult = true; + } + return result; + }) as typeof execFileSync); + expect(() => createTaskClone({ ...input, timeoutMs: 1000 })).toThrow('deadline'); + expect(lateResult).toBe(true); + expect(readdirSync(input.parent)).toEqual([]); + vi.mocked(execFileSync).mockImplementation(actual.execFileSync); + }); it('does not inherit Git directory, index, configuration or object overrides', () => { const input = fixture(); const keys = ['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0']; diff --git a/test/agent-contract.test.ts b/test/agent-contract.test.ts index 04e96ce..c498231 100644 --- a/test/agent-contract.test.ts +++ b/test/agent-contract.test.ts @@ -39,4 +39,10 @@ describe('invocation boundary', () => { expect(() => captureInvocation({ ...request(), phase: 'shell' } as unknown as InvocationInput, 1000)).toThrow('profile'); expect(() => captureInvocation({ ...request(), attemptId: '' }, 1000)).toThrow('identity'); }); + it('rejects sparse allowlists with missing arguments or commands', () => { + const argv = ['npm', 'test']; delete argv[1]; + expect(1 in argv).toBe(false); + expect(() => captureInvocation({ ...request(), approvedArgv: [argv] }, 1000)).toThrow('argv'); + expect(() => captureInvocation({ ...request(), approvedArgv: new Array(1) }, 1000)).toThrow('argv'); + }); }); From 17d71d0697536e9fd5d240b6a63a3074ee7fb698 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 08:33:31 -0700 Subject: [PATCH 03/44] Add D2 pinned restricted agent containers --- .github/workflows/agent-isolation.yml | 25 +++ agents/container/Dockerfile | 22 +++ agents/container/image.ts | 33 ++++ agents/container/probe.sh | 79 +++++++++ agents/container/profile.ts | 100 +++++++++++ agents/container/run.ts | 236 ++++++++++++++++++++++++++ test/agent-container.test.ts | 200 ++++++++++++++++++++++ 7 files changed, 695 insertions(+) create mode 100644 .github/workflows/agent-isolation.yml create mode 100644 agents/container/Dockerfile create mode 100644 agents/container/image.ts create mode 100644 agents/container/probe.sh create mode 100644 agents/container/profile.ts create mode 100644 agents/container/run.ts create mode 100644 test/agent-container.test.ts diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml new file mode 100644 index 0000000..b568c04 --- /dev/null +++ b/.github/workflows/agent-isolation.yml @@ -0,0 +1,25 @@ +name: Agent isolation +on: + push: + branches: ['codex/agent-isolation-d2'] + pull_request: + paths: + - 'agents/**' + - 'git/clone.ts' + - 'test/agent-*.test.ts' + - '.github/workflows/agent-isolation.yml' +permissions: + contents: read +jobs: + real-docker: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '26.7.0' + cache: npm + - run: npm ci --ignore-scripts + - run: npm run typecheck + - run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts diff --git a/agents/container/Dockerfile b/agents/container/Dockerfile new file mode 100644 index 0000000..c70ef94 --- /dev/null +++ b/agents/container/Dockerfile @@ -0,0 +1,22 @@ +FROM node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1 + +ARG CODEX_VERSION=0.153.4 +ARG CLAUDE_VERSION=2.1.281 + +RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \ + "@openai/codex@${CODEX_VERSION}" \ + "@anthropic-ai/claude-code@${CLAUDE_VERSION}" \ + && npm cache clean --force \ + && useradd --uid 10001 --user-group --no-create-home --shell /usr/sbin/nologin codeboost \ + && install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost + +COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe + +LABEL org.opencontainers.image.base.name="docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1" \ + io.codeboost.codex.version="0.153.4" \ + io.codeboost.claude.version="2.1.281" \ + io.codeboost.profile.version="1" + +USER 10001:10001 +WORKDIR /work +ENTRYPOINT ["/usr/local/bin/codeboost-container-probe"] diff --git a/agents/container/image.ts b/agents/container/image.ts new file mode 100644 index 0000000..15e8c7f --- /dev/null +++ b/agents/container/image.ts @@ -0,0 +1,33 @@ +import { execFileSync } from 'node:child_process'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const AGENT_IMAGE = 'codeboost-agent:node26-codex0.153.4-claude2.1.281'; +export const BASE_IMAGE = 'docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1'; +export const CODEX_VERSION = '0.153.4'; +export const CLAUDE_VERSION = '2.1.281'; + +const context = dirname(fileURLToPath(import.meta.url)); + +export function buildAgentImage(timeoutMs = 10 * 60_000): string { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Image build requires a finite positive deadline.'); + const deadline = performance.now() + timeoutMs; + const remaining = () => { + const value = Math.ceil(deadline - performance.now()); + if (value <= 0) throw new Error('Agent image build exceeded its overall deadline.'); + return value; + }; + execFileSync('docker', ['build', '--pull=false', '--tag', AGENT_IMAGE, context], { + timeout: remaining(), killSignal: 'SIGKILL', stdio: ['ignore', 'inherit', 'inherit'], + }); + const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], { + encoding: 'utf8', timeout: remaining(), stdio: ['ignore', 'pipe', 'pipe'], + }))[0] as { Id?: string; Config?: { User?: string; Labels?: Record } }; + const labels = inspect.Config?.Labels ?? {}; + if (!inspect.Id?.startsWith('sha256:') || inspect.Config?.User !== '10001:10001' + || labels['org.opencontainers.image.base.name'] !== BASE_IMAGE + || labels['io.codeboost.codex.version'] !== CODEX_VERSION + || labels['io.codeboost.claude.version'] !== CLAUDE_VERSION + || labels['io.codeboost.profile.version'] !== '1') throw new Error('Built agent image does not match the pinned profile.'); + return inspect.Id; +} diff --git a/agents/container/probe.sh b/agents/container/probe.sh new file mode 100644 index 0000000..78ec706 --- /dev/null +++ b/agents/container/probe.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +fail() { printf 'codeboost isolation probe: %s\n' "$1" >&2; exit 78; } +mount_options() { findmnt --noheadings --output OPTIONS --target "$1" 2>/dev/null || fail "missing mount: $1"; } +has_option() { printf '%s\n' "$1" | tr ',' '\n' | grep -Fxq "$2"; } +require_option() { has_option "$(mount_options "$1")" "$2" || fail "$1 must be mounted $2"; } +filesystem_bytes() { df -B1 --output=size "$1" | tail -n 1 | tr -d ' '; } +filesystem_inodes() { df --output=itotal "$1" | tail -n 1 | tr -d ' '; } +require_ceiling() { + [ "$(filesystem_bytes "$1")" -le "$2" ] || fail "$1 exceeds its byte limit" + [ "$(filesystem_inodes "$1")" -le "$3" ] || fail "$1 exceeds its inode limit" +} + +[ "$(id -u)" -ne 0 ] || fail 'agent process must not run as root' +for field in CapInh CapPrm CapEff CapBnd CapAmb; do + [ "$(awk -v name="$field:" '$1 == name { print $2 }' /proc/self/status)" = '0000000000000000' ] \ + || fail 'all capability sets must be empty' +done +[ "$(awk '/^NoNewPrivs:/ { print $2 }' /proc/self/status)" = '1' ] || fail 'no-new-privileges must be enabled' +require_option / ro + +[ "${HOME:-}" = '/home/codeboost' ] || fail 'HOME must be the isolated home directory' +[ "${CODEBOOST_PHASE:-}" != '' ] || fail 'phase is required' +[ "${CODEBOOST_VENDOR:-}" = 'codex' ] || [ "${CODEBOOST_VENDOR:-}" = 'claude' ] || fail 'vendor is required' + +[ "$(findmnt --noheadings --output FSTYPE --target /work)" = 'tmpfs' ] || fail '/work must use a bounded tmpfs task filesystem' +[ "$(findmnt --noheadings --output FSTYPE --target /work/.git)" = 'tmpfs' ] || fail 'Git metadata must use a separate tmpfs filesystem' +[ "$(stat -c %d /work)" != "$(stat -c %d /work/.git)" ] || fail 'Git metadata must not alias the work filesystem' +require_ceiling /work "${CODEBOOST_WORK_BYTES:-0}" "${CODEBOOST_WORK_INODES:-0}" +require_ceiling /work/.git "${CODEBOOST_METADATA_BYTES:-0}" "${CODEBOOST_METADATA_INODES:-0}" +require_option /work/.git ro +require_option /run/codeboost-input ro +for path in /work /work/.git; do + require_option "$path" nosuid + require_option "$path" nodev +done + +case "$CODEBOOST_PHASE" in + planning|questions|review) require_option /work ro ;; + execute|fix) require_option /work rw ;; + *) fail 'unsupported phase' ;; +esac + +for path in /tmp /home/codeboost; do + [ "$(findmnt --noheadings --output FSTYPE --target "$path")" = 'tmpfs' ] || fail "$path must use tmpfs" + require_option "$path" rw + require_option "$path" nosuid + require_option "$path" nodev +done +require_ceiling /tmp 33554432 4096 +require_ceiling /home/codeboost 1048576 128 + +[ -z "$(find /home/codeboost -mindepth 1 -maxdepth 1 -print -quit)" ] || fail 'HOME must begin empty' +[ -z "$(find /tmp -mindepth 1 -maxdepth 1 -print -quit)" ] || fail '/tmp must begin empty' +[ ! -e /var/run/docker.sock ] || fail 'Docker socket must not be mounted' + +case "$CODEBOOST_VENDOR" in + codex) + [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || fail 'Claude credential must not accompany Codex' + [ "${CODEX_HOME:-}" = '/run/codeboost-auth/codex' ] || fail 'CODEX_HOME must be isolated' + [ -f "$CODEX_HOME/auth.json" ] || fail 'Codex auth file is missing' + require_option "$CODEX_HOME" rw + require_option "$CODEX_HOME" nosuid + require_option "$CODEX_HOME" nodev + require_option "$CODEX_HOME/auth.json" ro + require_ceiling "$CODEX_HOME" 4194304 256 + ;; + claude) + [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || fail 'Claude credential is missing' + [ -z "${CODEX_HOME:-}" ] || fail 'Codex credential must not accompany Claude' + ;; +esac + +[ "$(git --version)" != '' ] || fail 'Git is unavailable' +[ "$(codex --version)" = 'codex-cli 0.153.4' ] || fail 'unexpected Codex version' +[ "$(claude --version | awk '{print $1}')" = '2.1.281' ] || fail 'unexpected Claude version' + +exec "$@" diff --git a/agents/container/profile.ts b/agents/container/profile.ts new file mode 100644 index 0000000..09b714f --- /dev/null +++ b/agents/container/profile.ts @@ -0,0 +1,100 @@ +import { createHash } from 'node:crypto'; +import { lstatSync, readdirSync, realpathSync } from 'node:fs'; +import type { InvocationInput, Phase } from '../contract.ts'; +import { AGENT_IMAGE } from './image.ts'; + +export interface TaskFilesystems { + readonly keeper: string; + readonly workVolume: string; + readonly metadataVolume: string; + readonly workBytes: number; + readonly workInodes: number; + readonly metadataBytes: number; + readonly metadataInodes: number; +} +export interface ContainerProfile { + readonly name: string; + readonly args: readonly string[]; + readonly expectedImage: string; + readonly phase: Phase; + readonly vendor: 'claude' | 'codex'; + readonly networkMode: 'none' | 'bridge'; + readonly filesystems: TaskFilesystems; + readonly inputDirectory: string; + readonly codexAuthFile?: string; + readonly command: readonly string[]; +} +export interface ProfileOptions { + readonly invocation: InvocationInput; + readonly filesystems: TaskFilesystems; + readonly inputDirectory: string; + readonly command: readonly string[]; + readonly codexAuthFile?: string; + readonly claudeToken?: string; +} + +const safeName = (value: string) => { + const prefix = value.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 24); + return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`; +}; +const mount = (parts: Record) => Object.entries(parts) + .map(([key, value]) => value === true ? key : `${key}=${value}`).join(','); +const mountSource = (path: string, kind: string) => { + if (!path || /[\0\n,]/.test(path)) throw new Error(`${kind} path cannot be represented as a Docker mount.`); + return path; +}; + +export function createContainerProfile(options: ProfileOptions): ContainerProfile { + const { invocation, filesystems } = options; + if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0'))) + throw new Error('Container command must be a complete literal argv array.'); + const inputStat = options.inputDirectory ? lstatSync(options.inputDirectory) : undefined; + if (!inputStat?.isDirectory() || (inputStat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be container-readable.'); + const inputDirectory = mountSource(realpathSync(options.inputDirectory), 'Schema input'); + const entries = readdirSync(inputDirectory); + const schema = entries.length === 1 && entries[0] === 'schema.json' ? lstatSync(`${inputDirectory}/schema.json`) : undefined; + if (!schema?.isFile() || schema.isSymbolicLink() || schema.nlink !== 1 || schema.size > 1024 * 1024 + || (schema.mode & 0o004) === 0) + throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.'); + if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) + throw new Error('Codex requires only its auth file.'); + if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile)) + throw new Error('Claude requires only its OAuth token.'); + if (options.claudeToken?.includes('\0')) throw new Error('Claude OAuth token is malformed.'); + if (!/^codeboost-work-[0-9a-f-]+$/.test(filesystems.workVolume) + || !/^codeboost-metadata-[0-9a-f-]+$/.test(filesystems.metadataVolume) + || !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.'); + if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) + throw new Error('Codex auth must be a direct regular file, not a link.'); + const codexAuthFile = options.codexAuthFile ? mountSource(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; + if (codexAuthFile) { + const auth = lstatSync(codexAuthFile); + if (!auth.isFile() || auth.isSymbolicLink() || auth.size > 1024 * 1024) throw new Error('Codex auth must be a bounded regular file.'); + } + const name = `codeboost-agent-${safeName(invocation.attemptId)}`; + const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); + const networkMode = 'none'; + const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', + '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1', + `--network=${networkMode}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', + '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, + '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, + '--env', 'XDG_CACHE_HOME=/tmp/xdg-cache', + '--tmpfs', '/tmp:rw,nosuid,nodev,size=33554432,nr_inodes=4096,mode=1777', + '--tmpfs', '/home/codeboost:rw,nosuid,nodev,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', + '--mount', mount({ type: 'volume', source: filesystems.workVolume, target: '/work', readonly: readOnlyWork }), + '--mount', mount({ type: 'volume', source: filesystems.metadataVolume, target: '/work/.git', readonly: true }), + '--mount', mount({ type: 'bind', source: inputDirectory, target: '/run/codeboost-input', readonly: true })]; + if (invocation.vendor === 'codex') { + args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', + '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', + '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); + } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); + args.push(AGENT_IMAGE, ...options.command); + const capturedFilesystems = Object.freeze({ ...filesystems }); + return Object.freeze({ name, args: Object.freeze(args), expectedImage: AGENT_IMAGE, + phase: invocation.phase, vendor: invocation.vendor, networkMode, + filesystems: capturedFilesystems, inputDirectory, codexAuthFile, + command: Object.freeze([...options.command]) }); +} diff --git a/agents/container/run.ts b/agents/container/run.ts new file mode 100644 index 0000000..09bf469 --- /dev/null +++ b/agents/container/run.ts @@ -0,0 +1,236 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstatSync, realpathSync } from 'node:fs'; +import type { ContainerProfile, TaskFilesystems } from './profile.ts'; +import { AGENT_IMAGE, BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; + +const dockerEnvironment = (secrets: Readonly> = {}) => ({ + PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, ...secrets, +}); +const validateSecrets = (profile: ContainerProfile, secrets: Readonly>) => { + const keys = Object.keys(secrets); + if (profile.vendor === 'codex' && keys.length) throw new Error('Codex profile must not receive environment credentials.'); + if (profile.vendor === 'claude' && (keys.length !== 1 || keys[0] !== 'CLAUDE_CODE_OAUTH_TOKEN' + || !secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.CLAUDE_CODE_OAUTH_TOKEN.includes('\0'))) + throw new Error('Claude profile requires only its OAuth environment credential.'); +}; +const docker = (args: readonly string[], options: { timeoutMs?: number; secrets?: Readonly> } = {}) => + execFileSync('docker', [...args], { encoding: 'utf8', timeout: options.timeoutMs ?? 30_000, + killSignal: 'SIGKILL', env: dockerEnvironment(options.secrets), stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +const validLimit = (value: number, name: string) => { + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`); +}; +const createDeadline = (timeoutMs: number) => { + validLimit(timeoutMs, 'timeoutMs'); + const deadline = performance.now() + timeoutMs; + return () => { + const value = Math.ceil(deadline - performance.now()); + if (value <= 0) throw new Error('Docker operation exceeded its overall deadline.'); + return value; + }; +}; +const resourceName = (kind: string) => `codeboost-${kind}-${randomUUID()}`; +const canonicalDockerBindSource = (source: string) => { + const desktopHostPath = source.startsWith('/host_mnt/') ? source.slice('/host_mnt'.length) : source; + try { return realpathSync(desktopHostPath); } catch { return source; } +}; + +export interface TaskStorageLimits { + readonly workBytes: number; + readonly workInodes: number; + readonly metadataBytes: number; + readonly metadataInodes: number; +} + +/** Allocate bounded, engine-owned task filesystems and keep them mounted. */ +export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskStorageLimits, + timeoutMs = 60_000): TaskFilesystems { + for (const [name, value] of Object.entries(limits)) validLimit(value, name); + const remaining = createDeadline(timeoutMs); + const staging = realpathSync(stagingDirectory); + if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); + if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); + const workVolume = resourceName('work'), metadataVolume = resourceName('metadata'), keeper = resourceName('keeper'); + const createdVolumes: string[] = []; + try { + for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes], + ['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) { + docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', + '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, + '--label', `io.codeboost.task-storage=${kind}`, name], { timeoutMs: remaining() }); + createdVolumes.push(name); + } + const seed = [ + 'set -eu', + 'cp -a /run/codeboost-staging/. /work/', + 'cp -a /work/.git/. /metadata/', + 'rm -rf /work/.git', + 'mkdir /work/.git', + 'touch /metadata/.codeboost-ready', + 'exec sleep infinity', + ].join('; '); + docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', + '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, + '--mount', `type=volume,source=${workVolume},target=/work`, + '--mount', `type=volume,source=${metadataVolume},target=/metadata`, + '--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sh', AGENT_IMAGE, '-c', seed], { timeoutMs: remaining() }); + while (true) { + const ready = spawnSync('docker', ['exec', keeper, 'sh', '-c', + 'test -f /metadata/.codeboost-ready && rm /metadata/.codeboost-ready'], { + timeout: remaining(), env: dockerEnvironment(), + stdio: ['ignore', 'ignore', 'ignore'], + }); + if (ready.status === 0) break; + if (ready.error) throw new Error('Timed out preparing bounded task filesystems.'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + return Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); + } catch (error) { + spawnSync('docker', ['rm', '--force', keeper], { env: dockerEnvironment(), stdio: 'ignore' }); + for (const volume of createdVolumes.reverse()) + spawnSync('docker', ['volume', 'rm', '--force', volume], { env: dockerEnvironment(), stdio: 'ignore' }); + throw error; + } +} + +type Inspect = { + Image: string; + Config: { Image: string; User: string; Env: string[]; Entrypoint: string[] | null; Cmd: string[] | null; WorkingDir: string }; + HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; + NetworkMode: string; PidMode: string; IpcMode: string; PidsLimit: number; Memory: number; NanoCpus: number; + Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; + Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; + Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; +}; + +/** Validate daemon-resolved configuration before starting an agent. */ +export function validateContainer(container: string, profile: ContainerProfile, timeoutMs = 30_000): void { + const remaining = createDeadline(timeoutMs); + const inspect = JSON.parse(docker(['container', 'inspect', container], { timeoutMs: remaining() }))[0] as Inspect | undefined; + if (!inspect) throw new Error('Docker did not return the created container.'); + const image = JSON.parse(docker(['image', 'inspect', profile.expectedImage], { timeoutMs: remaining() }))[0] as + { Id?: string; Config?: { User?: string; Entrypoint?: string[]; Labels?: Record } } | undefined; + const imageId = image?.Id, labels = image?.Config?.Labels ?? {}; + const host = inspect.HostConfig; + if (!imageId || inspect.Image !== imageId || inspect.Config.Image !== profile.expectedImage + || image?.Config?.User !== '10001:10001' + || JSON.stringify(image.Config?.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) + || labels['org.opencontainers.image.base.name'] !== BASE_IMAGE + || labels['io.codeboost.codex.version'] !== CODEX_VERSION + || labels['io.codeboost.claude.version'] !== CLAUDE_VERSION + || labels['io.codeboost.profile.version'] !== '1') + throw new Error('Container does not use the pinned agent image.'); + if (inspect.Config.User !== '10001:10001' || inspect.Config.WorkingDir !== '/work' + || JSON.stringify(inspect.Config.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) + || JSON.stringify(inspect.Config.Cmd) !== JSON.stringify(profile.command) + || !host.ReadonlyRootfs || host.Privileged + || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || !host.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) + || host.NetworkMode !== profile.networkMode || host.PidMode === 'host' || host.IpcMode === 'host' + || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 + || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) + throw new Error('Container daemon configuration is missing required lockdown.'); + const tmpfs = host.Tmpfs ?? {}; + for (const path of ['/tmp', '/home/codeboost']) if (!tmpfs[path]?.includes('size=')) + throw new Error(`Container is missing bounded tmpfs ${path}.`); + if (profile.vendor === 'codex' && !tmpfs['/run/codeboost-auth/codex']?.includes('size=')) + throw new Error('Codex state directory must be bounded tmpfs.'); + const mounts = new Map(inspect.Mounts.map(item => [item.Destination, item])); + const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', + ...(profile.vendor === 'codex' ? ['/run/codeboost-auth/codex/auth.json'] : [])]); + if (inspect.Mounts.some(item => !allowedMounts.has(item.Destination))) + throw new Error('Container includes an unexpected external mount.'); + const work = mounts.get('/work'), metadata = mounts.get('/work/.git'), input = mounts.get('/run/codeboost-input'); + if (work?.Type !== 'volume' || work.RW !== ['execute', 'fix'].includes(profile.phase) + || metadata?.Type !== 'volume' || metadata.RW || input?.Type !== 'bind' || input.RW) + throw new Error('Container mounts do not match the phase isolation profile.'); + const requestedMounts = new Map((host.Mounts ?? []).map(item => [item.Target, item])); + const requestedInput = requestedMounts.get('/run/codeboost-input'); + if (requestedInput?.Type !== 'bind' || canonicalDockerBindSource(requestedInput.Source) !== profile.inputDirectory + || !requestedInput.ReadOnly) throw new Error('Schema input mount identity changed.'); + if (work.Name !== profile.filesystems.workVolume || metadata.Name !== profile.filesystems.metadataVolume) + throw new Error('Container task volumes do not match their captured identity.'); + if (work.Source === metadata.Source) throw new Error('Worktree and Git metadata must use separate filesystems.'); + const volumes = JSON.parse(docker(['volume', 'inspect', work.Name!, metadata.Name!], { timeoutMs: remaining() })) as + Array<{ Name: string; Driver: string; Labels: Record | null; Options: Record | null }>; + const expectedVolumes = new Map([ + [work.Name!, ['work', String(profile.filesystems.workBytes), String(profile.filesystems.workInodes)]], + [metadata.Name!, ['metadata', String(profile.filesystems.metadataBytes), String(profile.filesystems.metadataInodes)]], + ]); + for (const volume of volumes) { + const expected = expectedVolumes.get(volume.Name), options = volume.Options ?? {}, optionString = options.o ?? ''; + if (!expected || volume.Driver !== 'local' || options.type !== 'tmpfs' || options.device !== 'tmpfs' + || volume.Labels?.['io.codeboost.task-storage'] !== expected[0] + || !optionString.split(',').includes(`size=${expected[1]}`) + || !optionString.split(',').includes(`nr_inodes=${expected[2]}`) + || !optionString.split(',').includes('nosuid') || !optionString.split(',').includes('nodev')) + throw new Error('Task volume does not match its bounded tmpfs allocation.'); + } + const keeper = JSON.parse(docker(['container', 'inspect', profile.filesystems.keeper], { timeoutMs: remaining() }))[0] as + { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record }; + HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; NetworkMode?: string; CapDrop?: string[] | null; + SecurityOpt?: string[] | null }; Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; + const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); + if (!keeper?.State?.Running || keeper.Config?.Image !== AGENT_IMAGE || keeper.Config?.User !== '10001:10001' + || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs + || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' + || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || !keeper.HostConfig.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) + || keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume + || keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume) + throw new Error('Task filesystems must remain owned by their trusted keeper.'); + const auth = mounts.get('/run/codeboost-auth/codex/auth.json'); + if (profile.vendor === 'codex' && (auth?.Type !== 'bind' || auth.RW)) throw new Error('Codex auth must be a read-only file mount.'); + const requestedAuth = requestedMounts.get('/run/codeboost-auth/codex/auth.json'); + if (profile.vendor === 'codex' && (requestedAuth?.Type !== 'bind' + || canonicalDockerBindSource(requestedAuth.Source) !== profile.codexAuthFile || !requestedAuth.ReadOnly)) + throw new Error('Codex auth mount identity changed.'); + if (profile.vendor === 'claude' && auth) throw new Error('Claude profile must not mount Codex auth.'); + if (inspect.Config.Env.some(value => value.indexOf('=') < 1)) throw new Error('Container environment is malformed.'); + const names = inspect.Config.Env.map(value => value.slice(0, value.indexOf('='))); + const environment = new Map(inspect.Config.Env.map(value => [value.slice(0, value.indexOf('=')), value.slice(value.indexOf('=') + 1)])); + const allowedEnvironment = new Set(['PATH', 'NODE_VERSION', 'YARN_VERSION', 'HOME', 'CODEBOOST_PHASE', 'CODEBOOST_VENDOR', + 'CODEBOOST_WORK_BYTES', 'CODEBOOST_WORK_INODES', 'CODEBOOST_METADATA_BYTES', 'CODEBOOST_METADATA_INODES', + 'npm_config_cache', 'XDG_CACHE_HOME', ...(profile.vendor === 'codex' ? ['CODEX_HOME'] : ['CLAUDE_CODE_OAUTH_TOKEN'])]); + if (new Set(names).size !== names.length || names.some(name => !allowedEnvironment.has(name))) + throw new Error('Container includes an unexpected environment variable.'); + if (environment.get('HOME') !== '/home/codeboost' || environment.get('CODEBOOST_PHASE') !== profile.phase + || environment.get('CODEBOOST_VENDOR') !== profile.vendor + || environment.get('CODEBOOST_WORK_BYTES') !== String(profile.filesystems.workBytes) + || environment.get('CODEBOOST_WORK_INODES') !== String(profile.filesystems.workInodes) + || environment.get('CODEBOOST_METADATA_BYTES') !== String(profile.filesystems.metadataBytes) + || environment.get('CODEBOOST_METADATA_INODES') !== String(profile.filesystems.metadataInodes)) + throw new Error('Container isolation environment changed.'); + if (profile.vendor === 'codex' && names.includes('CLAUDE_CODE_OAUTH_TOKEN')) throw new Error('Credential profiles must not be combined.'); + if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) + throw new Error('Credential profiles must not be combined.'); +} + +export function createValidatedContainer(profile: ContainerProfile, timeoutMs = 30_000, + secrets: Readonly> = {}): string { + const remaining = createDeadline(timeoutMs); + validateSecrets(profile, secrets); + try { + docker(profile.args, { timeoutMs: remaining(), secrets }); + validateContainer(profile.name, profile, remaining()); + return profile.name; + } catch (error) { + spawnSync('docker', ['rm', '--force', profile.name], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); + throw error; + } +} + +export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, + secrets: Readonly> = {}): string { + const remaining = createDeadline(timeoutMs); + const container = createValidatedContainer(profile, remaining(), secrets); + try { return docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); } + finally { spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); } +} + +export function removeTaskFilesystems(filesystems: TaskFilesystems): void { + spawnSync('docker', ['rm', '--force', filesystems.keeper], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); + for (const volume of [filesystems.metadataVolume, filesystems.workVolume]) + spawnSync('docker', ['volume', 'rm', '--force', volume], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); +} diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts new file mode 100644 index 0000000..f1476c0 --- /dev/null +++ b/test/agent-container.test.ts @@ -0,0 +1,200 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; +import { AGENT_IMAGE, buildAgentImage } from '../agents/container/image.ts'; +import { createContainerProfile } from '../agents/container/profile.ts'; +import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, + validateContainer } from '../agents/container/run.ts'; +import { createTaskClone } from '../git/clone.ts'; + +const roots: string[] = []; +const taskFilesystems: ReturnType[] = []; +const containers = new Set(); +const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], + { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +const docker = (...args: string[]) => execFileSync('docker', args, { + encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'agent-container-')); roots.push(root); + const source = join(root, 'source'), staging = join(root, 'staging'), input = join(root, 'input'); + mkdirSync(source); mkdirSync(staging); mkdirSync(input); + git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com'); + writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline'); + writeFileSync(join(input, 'schema.json'), '{"probe":"codeboost-schema-marker"}\n'); + chmodSync(join(input, 'schema.json'), 0o444); chmodSync(input, 0o555); + const clone = createTaskClone({ source, parent: staging, taskId: 'task-1', head: git(source, 'rev-parse', 'HEAD') }); + const filesystems = prepareTaskFilesystems(clone.directory, { + workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, + }); + taskFilesystems.push(filesystems); + const fakeAuth = join(root, 'auth.json'); writeFileSync(fakeAuth, '{}', { mode: 0o600 }); + return { root, source, input, clone, filesystems, fakeAuth }; +} + +function invocation(clone: ReturnType, phase: Phase, vendor: 'codex' | 'claude' = 'codex'): InvocationInput { + return captureInvocation({ clone, phase, vendor, approvedArgv: phase === 'planning' || phase === 'questions' ? [] : [['git', 'status']], + deadline: Date.now() + 60_000, attemptId: `${vendor}-${phase}-${Math.random().toString(16).slice(2)}`, + context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', + referencedCodeHash: 'code-1', stateVersion: 1 } }); +} + +function profile(data: ReturnType, phase: Phase, command: string[], options: { + vendor?: 'codex' | 'claude'; authProbe?: boolean; codexAuthFile?: string; claudeToken?: string; +} = {}) { + const vendor = options.vendor ?? 'codex'; + const base = createContainerProfile({ invocation: invocation(data.clone, phase, vendor), filesystems: data.filesystems, + inputDirectory: data.input, command, + codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, + claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); + if (!options.authProbe) return base; + // Test-only bridge access proves credentials work before D3 adds vendor-only egress. + return Object.freeze({ ...base, networkMode: 'bridge' as const, + args: Object.freeze(base.args.map(value => value === '--network=none' ? '--network=bridge' : value)) }); +} + +beforeAll(() => { buildAgentImage(); }, 10 * 60_000); +afterAll(() => { + for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); + for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); + for (const root of roots.reverse()) { + chmodSync(join(root, 'input'), 0o700); + rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } +}); + +describe('real Docker agent isolation', () => { + it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { + const data = fixture(); + process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; + try { + const output = runContainer(profile(data, 'planning', ['sh', '-c', [ + 'test "$(id -u)" = 10001', + 'test "$(git status --porcelain)" = ""', + 'test ! -e "$1"', + 'test -z "${HOST_SECRET_SENTINEL:-}"', + '! touch /work/forbidden', + '! touch /usr/bin/forbidden', + 'touch /tmp/allowed "$HOME/allowed"', + 'printf isolated', + ].join('; '), 'probe', data.source])); + expect(output).toBe('isolated'); + } finally { delete process.env.HOST_SECRET_SENTINEL; } + }, 60_000); + + it('persists execution changes while replacing HOME and scratch for each invocation', () => { + const data = fixture(); + expect(runContainer(profile(data, 'execute', ['sh', '-c', + 'printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first']))).toBe('first'); + const output = runContainer(profile(data, 'execute', ['sh', '-c', + 'test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain'])); + expect(output).toContain('?? generated.txt'); + }, 60_000); + + it('enforces work byte and inode ceilings before writes can exceed the allocation', () => { + const data = fixture(); + const output = runContainer(profile(data, 'execute', ['sh', '-c', [ + '! dd if=/dev/zero of=/work/overflow bs=1M count=32 2>/dev/null', + 'rm -f /work/overflow', + 'mkdir /work/many', + 'i=0; while touch "/work/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt 2000; done', + 'test "$i" -lt 2000', + 'rm -rf /work/many', + 'printf bounded', + ].join('; ')])); + expect(output).toBe('bounded'); + }, 60_000); + + it('keeps Git metadata read-only, on another filesystem, and mounted against replacement', () => { + const data = fixture(); + const output = runContainer(profile(data, 'execute', ['sh', '-c', [ + '! touch /work/.git/forbidden 2>/dev/null', + '! ln /work/.git/HEAD /work/metadata-link 2>/dev/null', + '! mv /work/.git /work/replaced 2>/dev/null', + 'git status --porcelain', + 'printf metadata-safe', + ].join('; ')])); + expect(output).toBe('metadata-safe'); + }, 60_000); + + it('refuses a container missing read-only root before its command runs', () => { + const data = fixture(); + const valid = profile(data, 'planning', ['sh', '-c', 'touch /tmp/command-ran']); + const args = valid.args.filter(value => value !== '--read-only'); + docker(...args); + containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + const result = spawnSync('docker', ['start', '--attach', valid.name], { encoding: 'utf8', timeout: 30_000 }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('must be mounted ro'); + containers.delete(valid.name); docker('rm', '--force', valid.name); + }, 60_000); + + it('rejects mixed credentials and unsupported command/profile inputs', () => { + const data = fixture(); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'codex'), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, + claudeToken: 'must-not-combine' })).toThrow('only'); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'] })).toThrow('OAuth'); + const claudeProfile = createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], claudeToken: 'serialization-sentinel' }); + expect(JSON.stringify(claudeProfile)).not.toContain('serialization-sentinel'); + expect(() => createValidatedContainer(claudeProfile)).toThrow('OAuth environment credential'); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + filesystems: data.filesystems, inputDirectory: data.input, command: [] })).toThrow('argv'); + chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); + expect(() => profile(data, 'planning', ['true'])).toThrow('only one bounded'); + }); + + it('rejects unexpected host mounts and unbounded task volumes after Docker resolves them', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const imageIndex = valid.args.indexOf(AGENT_IMAGE); + const extraMountArgs = [...valid.args.slice(0, imageIndex), '--mount', + 'type=bind,source=/tmp,target=/unexpected,readonly', ...valid.args.slice(imageIndex)]; + docker(...extraMountArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('unexpected external mount'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + + const rogue = `codeboost-work-${randomUUID()}`; docker('volume', 'create', rogue); + try { + const rogueArgs = valid.args.map(value => value.replace(data.filesystems.workVolume, rogue)); + const rogueProfile = Object.freeze({ ...valid, args: Object.freeze(rogueArgs), + filesystems: Object.freeze({ ...valid.filesystems, workVolume: rogue }) }); + docker(...rogueArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, rogueProfile)).toThrow('bounded tmpfs allocation'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + } finally { spawnSync('docker', ['volume', 'rm', '--force', rogue], { stdio: 'ignore' }); } + }, 60_000); + + if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { + it('runs the authenticated Codex startup path with isolated writable state', () => { + const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; + if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); + const output = runContainer(profile(data, 'planning', ['sh', '-c', [ + "codex exec --sandbox read-only --skip-git-repo-check --output-last-message /tmp/codex-output.txt 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.' >/tmp/codex-events.jsonl", + 'grep -Fx codeboost-schema-marker /tmp/codex-output.txt', + ].join('; ')], { authProbe: true, codexAuthFile: authFile }), 5 * 60_000); + expect(output).toBe('codeboost-schema-marker'); + }, 6 * 60_000); + + it('runs the authenticated Claude startup path with only its OAuth token', () => { + const data = fixture(), token = process.env.CLAUDE_CODE_OAUTH_TOKEN; + if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); + const output = runContainer(profile(data, 'planning', ['claude', '-p', + 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.', + '--output-format', 'json', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', + '--allowedTools', 'Read', '--add-dir', '/run/codeboost-input', + '--disallowedTools', 'WebFetch,WebSearch'], { vendor: 'claude', authProbe: true, claudeToken: token }), + 5 * 60_000, { CLAUDE_CODE_OAUTH_TOKEN: token }); + const envelope = JSON.parse(output) as { result?: string; is_error?: boolean }; + expect(envelope.is_error).not.toBe(true); + expect(envelope.result?.trim()).toBe('codeboost-schema-marker'); + }, 6 * 60_000); + } +}); From 124800b469deda8974de063f418b3ba95bdac323 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 08:50:02 -0700 Subject: [PATCH 04/44] Harden D2 container validation and Linux setup --- agents/container/Dockerfile | 3 +- agents/container/profile.ts | 14 +++---- agents/container/run.ts | 57 +++++++++++++++------------ test/agent-container.test.ts | 76 +++++++++++++++++++++++++----------- 4 files changed, 95 insertions(+), 55 deletions(-) diff --git a/agents/container/Dockerfile b/agents/container/Dockerfile index c70ef94..6711760 100644 --- a/agents/container/Dockerfile +++ b/agents/container/Dockerfile @@ -8,7 +8,8 @@ RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \ "@anthropic-ai/claude-code@${CLAUDE_VERSION}" \ && npm cache clean --force \ && useradd --uid 10001 --user-group --no-create-home --shell /usr/sbin/nologin codeboost \ - && install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost + && install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost \ + && install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 09b714f..28fb510 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto'; import { lstatSync, readdirSync, realpathSync } from 'node:fs'; import type { InvocationInput, Phase } from '../contract.ts'; -import { AGENT_IMAGE } from './image.ts'; export interface TaskFilesystems { readonly keeper: string; @@ -18,7 +17,6 @@ export interface ContainerProfile { readonly expectedImage: string; readonly phase: Phase; readonly vendor: 'claude' | 'codex'; - readonly networkMode: 'none' | 'bridge'; readonly filesystems: TaskFilesystems; readonly inputDirectory: string; readonly codexAuthFile?: string; @@ -29,6 +27,7 @@ export interface ProfileOptions { readonly filesystems: TaskFilesystems; readonly inputDirectory: string; readonly command: readonly string[]; + readonly imageId: string; readonly codexAuthFile?: string; readonly claudeToken?: string; } @@ -48,6 +47,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const { invocation, filesystems } = options; if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0'))) throw new Error('Container command must be a complete literal argv array.'); + if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) + throw new Error('Container profile requires the immutable built image ID.'); const inputStat = options.inputDirectory ? lstatSync(options.inputDirectory) : undefined; if (!inputStat?.isDirectory() || (inputStat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be container-readable.'); const inputDirectory = mountSource(realpathSync(options.inputDirectory), 'Schema input'); @@ -73,10 +74,9 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil } const name = `codeboost-agent-${safeName(invocation.attemptId)}`; const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); - const networkMode = 'none'; const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1', - `--network=${networkMode}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, @@ -91,10 +91,10 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); - args.push(AGENT_IMAGE, ...options.command); + args.push(options.imageId, ...options.command); const capturedFilesystems = Object.freeze({ ...filesystems }); - return Object.freeze({ name, args: Object.freeze(args), expectedImage: AGENT_IMAGE, - phase: invocation.phase, vendor: invocation.vendor, networkMode, + return Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, + phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory, codexAuthFile, command: Object.freeze([...options.command]) }); } diff --git a/agents/container/run.ts b/agents/container/run.ts index 09bf469..92715bb 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; import type { ContainerProfile, TaskFilesystems } from './profile.ts'; -import { AGENT_IMAGE, BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; +import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, ...secrets, @@ -44,8 +44,9 @@ export interface TaskStorageLimits { /** Allocate bounded, engine-owned task filesystems and keep them mounted. */ export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskStorageLimits, - timeoutMs = 60_000): TaskFilesystems { + imageId: string, timeoutMs = 60_000): TaskFilesystems { for (const [name, value] of Object.entries(limits)) validLimit(value, name); + if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); const remaining = createDeadline(timeoutMs); const staging = realpathSync(stagingDirectory); if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); @@ -62,29 +63,25 @@ export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskSto } const seed = [ 'set -eu', - 'cp -a /run/codeboost-staging/. /work/', - 'cp -a /work/.git/. /metadata/', + 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/', + 'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/', 'rm -rf /work/.git', 'mkdir /work/.git', - 'touch /metadata/.codeboost-ready', - 'exec sleep infinity', + 'chown -R 10001:10001 /work /metadata', ].join('; '); docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', + '--mount', `type=volume,source=${workVolume},target=/work`, + '--mount', `type=volume,source=${metadataVolume},target=/metadata`, + '--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sleep', imageId, 'infinity'], + { timeoutMs: remaining() }); + docker(['run', '--rm', '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', + '--cap-add=CHOWN', '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32', + '--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, - '--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sh', AGENT_IMAGE, '-c', seed], { timeoutMs: remaining() }); - while (true) { - const ready = spawnSync('docker', ['exec', keeper, 'sh', '-c', - 'test -f /metadata/.codeboost-ready && rm /metadata/.codeboost-ready'], { - timeout: remaining(), env: dockerEnvironment(), - stdio: ['ignore', 'ignore', 'ignore'], - }); - if (ready.status === 0) break; - if (ready.error) throw new Error('Timed out preparing bounded task filesystems.'); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); - } + '--entrypoint', 'sh', imageId, '-c', seed], { timeoutMs: remaining() }); return Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); } catch (error) { spawnSync('docker', ['rm', '--force', keeper], { env: dockerEnvironment(), stdio: 'ignore' }); @@ -113,7 +110,8 @@ export function validateContainer(container: string, profile: ContainerProfile, { Id?: string; Config?: { User?: string; Entrypoint?: string[]; Labels?: Record } } | undefined; const imageId = image?.Id, labels = image?.Config?.Labels ?? {}; const host = inspect.HostConfig; - if (!imageId || inspect.Image !== imageId || inspect.Config.Image !== profile.expectedImage + if (!imageId || imageId !== profile.expectedImage || inspect.Image !== profile.expectedImage + || inspect.Config.Image !== profile.expectedImage || image?.Config?.User !== '10001:10001' || JSON.stringify(image.Config?.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) || labels['org.opencontainers.image.base.name'] !== BASE_IMAGE @@ -127,15 +125,22 @@ export function validateContainer(container: string, profile: ContainerProfile, || !host.ReadonlyRootfs || host.Privileged || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || !host.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) - || host.NetworkMode !== profile.networkMode || host.PidMode === 'host' || host.IpcMode === 'host' + || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) throw new Error('Container daemon configuration is missing required lockdown.'); const tmpfs = host.Tmpfs ?? {}; - for (const path of ['/tmp', '/home/codeboost']) if (!tmpfs[path]?.includes('size=')) - throw new Error(`Container is missing bounded tmpfs ${path}.`); - if (profile.vendor === 'codex' && !tmpfs['/run/codeboost-auth/codex']?.includes('size=')) - throw new Error('Codex state directory must be bounded tmpfs.'); + const expectedTmpfs = new Map([ + ['/tmp', ['rw', 'nosuid', 'nodev', 'size=33554432', 'nr_inodes=4096', 'mode=1777']], + ['/home/codeboost', ['rw', 'nosuid', 'nodev', 'size=1048576', 'nr_inodes=128', 'uid=10001', 'gid=10001', 'mode=0700']], + ...(profile.vendor === 'codex' ? [['/run/codeboost-auth/codex', + ['rw', 'nosuid', 'nodev', 'size=4194304', 'nr_inodes=256', 'uid=10001', 'gid=10001', 'mode=0700']] as const] : []), + ]); + if (Object.keys(tmpfs).length !== expectedTmpfs.size) throw new Error('Container tmpfs mount set changed.'); + for (const [path, expected] of expectedTmpfs) { + const actual = new Set((tmpfs[path] ?? '').split(',')); + if (expected.some(option => !actual.has(option))) throw new Error(`Container tmpfs ${path} is missing required options.`); + } const mounts = new Map(inspect.Mounts.map(item => [item.Destination, item])); const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', ...(profile.vendor === 'codex' ? ['/run/codeboost-auth/codex/auth.json'] : [])]); @@ -164,7 +169,9 @@ export function validateContainer(container: string, profile: ContainerProfile, || volume.Labels?.['io.codeboost.task-storage'] !== expected[0] || !optionString.split(',').includes(`size=${expected[1]}`) || !optionString.split(',').includes(`nr_inodes=${expected[2]}`) - || !optionString.split(',').includes('nosuid') || !optionString.split(',').includes('nodev')) + || !optionString.split(',').includes('uid=10001') || !optionString.split(',').includes('gid=10001') + || !optionString.split(',').includes('mode=0755') || !optionString.split(',').includes('nosuid') + || !optionString.split(',').includes('nodev')) throw new Error('Task volume does not match its bounded tmpfs allocation.'); } const keeper = JSON.parse(docker(['container', 'inspect', profile.filesystems.keeper], { timeoutMs: remaining() }))[0] as @@ -172,7 +179,7 @@ export function validateContainer(container: string, profile: ContainerProfile, HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; NetworkMode?: string; CapDrop?: string[] | null; SecurityOpt?: string[] | null }; Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); - if (!keeper?.State?.Running || keeper.Config?.Image !== AGENT_IMAGE || keeper.Config?.User !== '10001:10001' + if (!keeper?.State?.Running || keeper.Config?.Image !== profile.expectedImage || keeper.Config?.User !== '10001:10001' || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index f1476c0..08e3926 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -14,6 +14,7 @@ import { createTaskClone } from '../git/clone.ts'; const roots: string[] = []; const taskFilesystems: ReturnType[] = []; const containers = new Set(); +let imageId = ''; const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const docker = (...args: string[]) => execFileSync('docker', args, { @@ -31,7 +32,7 @@ function fixture() { const clone = createTaskClone({ source, parent: staging, taskId: 'task-1', head: git(source, 'rev-parse', 'HEAD') }); const filesystems = prepareTaskFilesystems(clone.directory, { workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, - }); + }, imageId); taskFilesystems.push(filesystems); const fakeAuth = join(root, 'auth.json'); writeFileSync(fakeAuth, '{}', { mode: 0o600 }); return { root, source, input, clone, filesystems, fakeAuth }; @@ -50,15 +51,13 @@ function profile(data: ReturnType, phase: Phase, command: string const vendor = options.vendor ?? 'codex'; const base = createContainerProfile({ invocation: invocation(data.clone, phase, vendor), filesystems: data.filesystems, inputDirectory: data.input, command, + imageId, codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); - if (!options.authProbe) return base; - // Test-only bridge access proves credentials work before D3 adds vendor-only egress. - return Object.freeze({ ...base, networkMode: 'bridge' as const, - args: Object.freeze(base.args.map(value => value === '--network=none' ? '--network=bridge' : value)) }); + return base; } -beforeAll(() => { buildAgentImage(); }, 10 * 60_000); +beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000); afterAll(() => { for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); @@ -73,7 +72,7 @@ describe('real Docker agent isolation', () => { const data = fixture(); process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; try { - const output = runContainer(profile(data, 'planning', ['sh', '-c', [ + const output = runContainer(profile(data, 'planning', ['sh', '-c', ['set -eu', 'test "$(id -u)" = 10001', 'test "$(git status --porcelain)" = ""', 'test ! -e "$1"', @@ -90,20 +89,21 @@ describe('real Docker agent isolation', () => { it('persists execution changes while replacing HOME and scratch for each invocation', () => { const data = fixture(); expect(runContainer(profile(data, 'execute', ['sh', '-c', - 'printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first']))).toBe('first'); + 'set -eu; printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first']))).toBe('first'); const output = runContainer(profile(data, 'execute', ['sh', '-c', - 'test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain'])); + 'set -eu; test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain'])); expect(output).toContain('?? generated.txt'); }, 60_000); it('enforces work byte and inode ceilings before writes can exceed the allocation', () => { const data = fixture(); - const output = runContainer(profile(data, 'execute', ['sh', '-c', [ + const output = runContainer(profile(data, 'execute', ['sh', '-c', ['set -eu', '! dd if=/dev/zero of=/work/overflow bs=1M count=32 2>/dev/null', 'rm -f /work/overflow', 'mkdir /work/many', 'i=0; while touch "/work/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt 2000; done', 'test "$i" -lt 2000', + 'test "$(find /work/many -type f | wc -l)" -eq "$i"', 'rm -rf /work/many', 'printf bounded', ].join('; ')])); @@ -112,7 +112,7 @@ describe('real Docker agent isolation', () => { it('keeps Git metadata read-only, on another filesystem, and mounted against replacement', () => { const data = fixture(); - const output = runContainer(profile(data, 'execute', ['sh', '-c', [ + const output = runContainer(profile(data, 'execute', ['sh', '-c', ['set -eu', '! touch /work/.git/forbidden 2>/dev/null', '! ln /work/.git/HEAD /work/metadata-link 2>/dev/null', '! mv /work/.git /work/replaced 2>/dev/null', @@ -131,7 +131,6 @@ describe('real Docker agent isolation', () => { expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); const result = spawnSync('docker', ['start', '--attach', valid.name], { encoding: 'utf8', timeout: 30_000 }); expect(result.status).not.toBe(0); - expect(result.stderr).toContain('must be mounted ro'); containers.delete(valid.name); docker('rm', '--force', valid.name); }, 60_000); @@ -139,22 +138,26 @@ describe('real Docker agent isolation', () => { const data = fixture(); expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'codex'), filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - claudeToken: 'must-not-combine' })).toThrow('only'); + claudeToken: 'must-not-combine', imageId })).toThrow('only'); expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'] })).toThrow('OAuth'); + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId })).toThrow('OAuth'); const claudeProfile = createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], claudeToken: 'serialization-sentinel' }); + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, + claudeToken: 'serialization-sentinel' }); expect(JSON.stringify(claudeProfile)).not.toContain('serialization-sentinel'); expect(() => createValidatedContainer(claudeProfile)).toThrow('OAuth environment credential'); expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), - filesystems: data.filesystems, inputDirectory: data.input, command: [] })).toThrow('argv'); + filesystems: data.filesystems, inputDirectory: data.input, command: [], imageId })).toThrow('argv'); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, + imageId: AGENT_IMAGE })).toThrow('immutable built image ID'); chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => profile(data, 'planning', ['true'])).toThrow('only one bounded'); }); it('rejects unexpected host mounts and unbounded task volumes after Docker resolves them', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); - const imageIndex = valid.args.indexOf(AGENT_IMAGE); + const imageIndex = valid.args.indexOf(imageId); const extraMountArgs = [...valid.args.slice(0, imageIndex), '--mount', 'type=bind,source=/tmp,target=/unexpected,readonly', ...valid.args.slice(imageIndex)]; docker(...extraMountArgs); containers.add(valid.name); @@ -172,26 +175,55 @@ describe('real Docker agent isolation', () => { } finally { spawnSync('docker', ['volume', 'rm', '--force', rogue], { stdio: 'ignore' }); } }, 60_000); + it('rejects a caller-mutated network before the container can start', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const args = valid.args.map(value => value === '--network=none' ? '--network=bridge' : value); + docker(...args); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + const state = JSON.parse(docker('container', 'inspect', valid.name))[0] as { State: { Status: string } }; + expect(state.State.Status).toBe('created'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + }, 60_000); + + it('creates containers from the captured immutable image rather than its mutable tag', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + expect(valid.expectedImage).toBe(imageId); + expect(valid.args).toContain(imageId); + expect(valid.args).not.toContain(AGENT_IMAGE); + expect(() => prepareTaskFilesystems(data.clone.directory, { + workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, + }, AGENT_IMAGE)).toThrow('immutable built image ID'); + }); + if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { it('runs the authenticated Codex startup path with isolated writable state', () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); - const output = runContainer(profile(data, 'planning', ['sh', '-c', [ + const authProfile = profile(data, 'planning', ['sh', '-c', [ "codex exec --sandbox read-only --skip-git-repo-check --output-last-message /tmp/codex-output.txt 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.' >/tmp/codex-events.jsonl", 'grep -Fx codeboost-schema-marker /tmp/codex-output.txt', - ].join('; ')], { authProbe: true, codexAuthFile: authFile }), 5 * 60_000); + ].join('; ')], { authProbe: true, codexAuthFile: authFile }); + const args = authProfile.args.map(value => value === '--network=none' ? '--network=bridge' : value); + docker(...args); containers.add(authProfile.name); + const output = docker('start', '--attach', authProfile.name); + docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); expect(output).toBe('codeboost-schema-marker'); }, 6 * 60_000); it('runs the authenticated Claude startup path with only its OAuth token', () => { const data = fixture(), token = process.env.CLAUDE_CODE_OAUTH_TOKEN; if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); - const output = runContainer(profile(data, 'planning', ['claude', '-p', + const authProfile = profile(data, 'planning', ['claude', '-p', 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.', '--output-format', 'json', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--allowedTools', 'Read', '--add-dir', '/run/codeboost-input', - '--disallowedTools', 'WebFetch,WebSearch'], { vendor: 'claude', authProbe: true, claudeToken: token }), - 5 * 60_000, { CLAUDE_CODE_OAUTH_TOKEN: token }); + '--disallowedTools', 'WebFetch,WebSearch'], { vendor: 'claude', authProbe: true, claudeToken: token }); + const args = authProfile.args.map(value => value === '--network=none' ? '--network=bridge' : value); + const result = execFileSync('docker', args, { encoding: 'utf8', timeout: 60_000, + env: { PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, CLAUDE_CODE_OAUTH_TOKEN: token } }); + void result; containers.add(authProfile.name); + const output = docker('start', '--attach', authProfile.name); + docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); const envelope = JSON.parse(output) as { result?: string; is_error?: boolean }; expect(envelope.is_error).not.toBe(true); expect(envelope.result?.trim()).toBe('codeboost-schema-marker'); From e02995f76b800d57a156df18f8bf40fb6c4f9c50 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:02:28 -0700 Subject: [PATCH 05/44] Close D2 profile and validation trust gaps --- agents/container/image.ts | 1 + agents/container/probe.sh | 2 + agents/container/profile.ts | 80 +++++++++++++++++++++++++++++------- agents/container/run.ts | 28 ++++++++++--- test/agent-container.test.ts | 35 ++++++++++++++-- 5 files changed, 123 insertions(+), 23 deletions(-) diff --git a/agents/container/image.ts b/agents/container/image.ts index 15e8c7f..876a029 100644 --- a/agents/container/image.ts +++ b/agents/container/image.ts @@ -23,6 +23,7 @@ export function buildAgentImage(timeoutMs = 10 * 60_000): string { const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], { encoding: 'utf8', timeout: remaining(), stdio: ['ignore', 'pipe', 'pipe'], }))[0] as { Id?: string; Config?: { User?: string; Labels?: Record } }; + remaining(); const labels = inspect.Config?.Labels ?? {}; if (!inspect.Id?.startsWith('sha256:') || inspect.Config?.User !== '10001:10001' || labels['org.opencontainers.image.base.name'] !== BASE_IMAGE diff --git a/agents/container/probe.sh b/agents/container/probe.sh index 78ec706..df56239 100644 --- a/agents/container/probe.sh +++ b/agents/container/probe.sh @@ -1,5 +1,7 @@ #!/bin/sh set -eu +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +export PATH fail() { printf 'codeboost isolation probe: %s\n' "$1" >&2; exit 78; } mount_options() { findmnt --noheadings --output OPTIONS --target "$1" 2>/dev/null || fail "missing mount: $1"; } diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 28fb510..3df0e75 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { lstatSync, readdirSync, realpathSync } from 'node:fs'; +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; import type { InvocationInput, Phase } from '../contract.ts'; export interface TaskFilesystems { @@ -32,6 +32,65 @@ export interface ProfileOptions { readonly claudeToken?: string; } +interface FileIdentity { + readonly path: string; + readonly dev: number; + readonly ino: number; + readonly mode: number; + readonly nlink: number; + readonly size: number; + readonly mtimeMs: number; + readonly digest: string; +} +interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity } +const identities = new WeakMap(); + +const captureFile = (path: string, kind: string): FileIdentity => { + let fd: number | undefined; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(fd); + if (!before.isFile() || before.nlink !== 1 || before.size > 1024 * 1024) + throw new Error(`${kind} must be a bounded, unlinked regular file.`); + const content = readFileSync(fd); + const after = fstatSync(fd); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) + throw new Error(`${kind} changed while its identity was captured.`); + return Object.freeze({ path, dev: after.dev, ino: after.ino, mode: after.mode, nlink: after.nlink, + size: after.size, mtimeMs: after.mtimeMs, digest: createHash('sha256').update(content).digest('hex') }); + } finally { if (fd !== undefined) closeSync(fd); } +}; +const sameFile = (actual: FileIdentity, expected: FileIdentity) => actual.path === expected.path + && actual.dev === expected.dev && actual.ino === expected.ino && actual.mode === expected.mode + && actual.nlink === expected.nlink && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs + && actual.digest === expected.digest; +const captureInput = (directory: string): ProfileIdentity => { + const stat = lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o005) !== 0o005) + throw new Error('Schema input directory must be a container-readable real directory.'); + const canonical = mountSource(realpathSync(directory), 'Schema input'); + const entries = readdirSync(canonical); + if (entries.length !== 1 || entries[0] !== 'schema.json') + throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.'); + const schema = captureFile(`${canonical}/schema.json`, 'Schema input'); + if ((schema.mode & 0o004) === 0) throw new Error('Schema input must be container-readable.'); + return Object.freeze({ inputDirectory: canonical, schema }); +}; + +/** Internal authenticity and host-file revalidation used at every launch boundary. */ +export function assertContainerProfile(profile: ContainerProfile): void { + const expected = identities.get(profile); + if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); + const actual = captureInput(expected.inputDirectory); + if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) + throw new Error('Schema input changed after the profile was captured.'); + if (expected.auth) { + const auth = captureFile(expected.auth.path, 'Codex auth'); + if (!sameFile(auth, expected.auth)) throw new Error('Codex auth changed after the profile was captured.'); + } +} + const safeName = (value: string) => { const prefix = value.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 24); return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`; @@ -49,14 +108,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container command must be a complete literal argv array.'); if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) throw new Error('Container profile requires the immutable built image ID.'); - const inputStat = options.inputDirectory ? lstatSync(options.inputDirectory) : undefined; - if (!inputStat?.isDirectory() || (inputStat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be container-readable.'); - const inputDirectory = mountSource(realpathSync(options.inputDirectory), 'Schema input'); - const entries = readdirSync(inputDirectory); - const schema = entries.length === 1 && entries[0] === 'schema.json' ? lstatSync(`${inputDirectory}/schema.json`) : undefined; - if (!schema?.isFile() || schema.isSymbolicLink() || schema.nlink !== 1 || schema.size > 1024 * 1024 - || (schema.mode & 0o004) === 0) - throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.'); + const inputIdentity = captureInput(options.inputDirectory); + const inputDirectory = inputIdentity.inputDirectory; if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile)) @@ -68,10 +121,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) throw new Error('Codex auth must be a direct regular file, not a link.'); const codexAuthFile = options.codexAuthFile ? mountSource(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; - if (codexAuthFile) { - const auth = lstatSync(codexAuthFile); - if (!auth.isFile() || auth.isSymbolicLink() || auth.size > 1024 * 1024) throw new Error('Codex auth must be a bounded regular file.'); - } + const authIdentity = codexAuthFile ? captureFile(codexAuthFile, 'Codex auth') : undefined; const name = `codeboost-agent-${safeName(invocation.attemptId)}`; const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', @@ -93,8 +143,10 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); args.push(options.imageId, ...options.command); const capturedFilesystems = Object.freeze({ ...filesystems }); - return Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, + const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory, codexAuthFile, command: Object.freeze([...options.command]) }); + identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity })); + return profile; } diff --git a/agents/container/run.ts b/agents/container/run.ts index 92715bb..709c902 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -1,7 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; -import type { ContainerProfile, TaskFilesystems } from './profile.ts'; +import { assertContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ @@ -30,6 +30,8 @@ const createDeadline = (timeoutMs: number) => { }; }; const resourceName = (kind: string) => `codeboost-${kind}-${randomUUID()}`; +const exactNoNewPrivileges = (options: string[] | null | undefined) => options?.length === 1 + && (options[0] === 'no-new-privileges' || options[0] === 'no-new-privileges:true'); const canonicalDockerBindSource = (source: string) => { const desktopHostPath = source.startsWith('/host_mnt/') ? source.slice('/host_mnt'.length) : source; try { return realpathSync(desktopHostPath); } catch { return source; } @@ -82,6 +84,7 @@ export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskSto '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, '--entrypoint', 'sh', imageId, '-c', seed], { timeoutMs: remaining() }); + remaining(); return Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); } catch (error) { spawnSync('docker', ['rm', '--force', keeper], { env: dockerEnvironment(), stdio: 'ignore' }); @@ -104,10 +107,11 @@ type Inspect = { /** Validate daemon-resolved configuration before starting an agent. */ export function validateContainer(container: string, profile: ContainerProfile, timeoutMs = 30_000): void { const remaining = createDeadline(timeoutMs); + assertContainerProfile(profile); const inspect = JSON.parse(docker(['container', 'inspect', container], { timeoutMs: remaining() }))[0] as Inspect | undefined; if (!inspect) throw new Error('Docker did not return the created container.'); const image = JSON.parse(docker(['image', 'inspect', profile.expectedImage], { timeoutMs: remaining() }))[0] as - { Id?: string; Config?: { User?: string; Entrypoint?: string[]; Labels?: Record } } | undefined; + { Id?: string; Config?: { User?: string; Env?: string[]; Entrypoint?: string[]; Labels?: Record } } | undefined; const imageId = image?.Id, labels = image?.Config?.Labels ?? {}; const host = inspect.HostConfig; if (!imageId || imageId !== profile.expectedImage || inspect.Image !== profile.expectedImage @@ -124,7 +128,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || JSON.stringify(inspect.Config.Cmd) !== JSON.stringify(profile.command) || !host.ReadonlyRootfs || host.Privileged || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') - || !host.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) + || !exactNoNewPrivileges(host.SecurityOpt) || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) @@ -183,7 +187,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') - || !keeper.HostConfig.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) + || !exactNoNewPrivileges(keeper.HostConfig.SecurityOpt) || keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume || keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume) throw new Error('Task filesystems must remain owned by their trusted keeper.'); @@ -197,12 +201,14 @@ export function validateContainer(container: string, profile: ContainerProfile, if (inspect.Config.Env.some(value => value.indexOf('=') < 1)) throw new Error('Container environment is malformed.'); const names = inspect.Config.Env.map(value => value.slice(0, value.indexOf('='))); const environment = new Map(inspect.Config.Env.map(value => [value.slice(0, value.indexOf('=')), value.slice(value.indexOf('=') + 1)])); + const imageEnvironment = new Map((image?.Config?.Env ?? []).map(value => [value.slice(0, value.indexOf('=')), value.slice(value.indexOf('=') + 1)])); const allowedEnvironment = new Set(['PATH', 'NODE_VERSION', 'YARN_VERSION', 'HOME', 'CODEBOOST_PHASE', 'CODEBOOST_VENDOR', 'CODEBOOST_WORK_BYTES', 'CODEBOOST_WORK_INODES', 'CODEBOOST_METADATA_BYTES', 'CODEBOOST_METADATA_INODES', 'npm_config_cache', 'XDG_CACHE_HOME', ...(profile.vendor === 'codex' ? ['CODEX_HOME'] : ['CLAUDE_CODE_OAUTH_TOKEN'])]); if (new Set(names).size !== names.length || names.some(name => !allowedEnvironment.has(name))) throw new Error('Container includes an unexpected environment variable.'); - if (environment.get('HOME') !== '/home/codeboost' || environment.get('CODEBOOST_PHASE') !== profile.phase + if (environment.get('PATH') !== imageEnvironment.get('PATH') + || environment.get('HOME') !== '/home/codeboost' || environment.get('CODEBOOST_PHASE') !== profile.phase || environment.get('CODEBOOST_VENDOR') !== profile.vendor || environment.get('CODEBOOST_WORK_BYTES') !== String(profile.filesystems.workBytes) || environment.get('CODEBOOST_WORK_INODES') !== String(profile.filesystems.workInodes) @@ -212,15 +218,20 @@ export function validateContainer(container: string, profile: ContainerProfile, if (profile.vendor === 'codex' && names.includes('CLAUDE_CODE_OAUTH_TOKEN')) throw new Error('Credential profiles must not be combined.'); if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) throw new Error('Credential profiles must not be combined.'); + assertContainerProfile(profile); + remaining(); } export function createValidatedContainer(profile: ContainerProfile, timeoutMs = 30_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); validateSecrets(profile, secrets); + assertContainerProfile(profile); try { docker(profile.args, { timeoutMs: remaining(), secrets }); validateContainer(profile.name, profile, remaining()); + assertContainerProfile(profile); + remaining(); return profile.name; } catch (error) { spawnSync('docker', ['rm', '--force', profile.name], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); @@ -232,7 +243,12 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); const container = createValidatedContainer(profile, remaining(), secrets); - try { return docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); } + try { + assertContainerProfile(profile); + const output = docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); + remaining(); + return output; + } finally { spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); } } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 08e3926..ef17210 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -167,14 +167,43 @@ describe('real Docker agent isolation', () => { const rogue = `codeboost-work-${randomUUID()}`; docker('volume', 'create', rogue); try { const rogueArgs = valid.args.map(value => value.replace(data.filesystems.workVolume, rogue)); - const rogueProfile = Object.freeze({ ...valid, args: Object.freeze(rogueArgs), - filesystems: Object.freeze({ ...valid.filesystems, workVolume: rogue }) }); docker(...rogueArgs); containers.add(valid.name); - expect(() => validateContainer(valid.name, rogueProfile)).toThrow('bounded tmpfs allocation'); + expect(() => validateContainer(valid.name, valid)).toThrow('captured identity'); docker('rm', '--force', valid.name); containers.delete(valid.name); } finally { spawnSync('docker', ['volume', 'rm', '--force', rogue], { stdio: 'ignore' }); } }, 60_000); + it('rejects cloned profiles and host inputs changed after capture', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const forged = Object.freeze({ ...valid, inputDirectory: '/', + args: Object.freeze(valid.args.map(value => value.includes(`source=${data.input},`) + ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); + expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); + + chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); + expect(() => createValidatedContainer(valid)).toThrow('only one bounded'); + chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); + + writeFileSync(data.fakeAuth, '{"changed":true}'); + expect(() => createValidatedContainer(valid)).toThrow('Codex auth changed'); + writeFileSync(data.fakeAuth, '{}'); + }); + + it('rejects extra security policies and a PATH that can shadow the startup probe', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const imageIndex = valid.args.indexOf(imageId); + const securityArgs = [...valid.args.slice(0, imageIndex), '--security-opt', 'seccomp=unconfined', + ...valid.args.slice(imageIndex)]; + docker(...securityArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + + const pathArgs = [...valid.args.slice(0, imageIndex), '--env', 'PATH=/work', ...valid.args.slice(imageIndex)]; + docker(...pathArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow(/environment|PATH/); + docker('rm', '--force', valid.name); containers.delete(valid.name); + }, 60_000); + it('rejects a caller-mutated network before the container can start', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const args = valid.args.map(value => value === '--network=none' ? '--network=bridge' : value); From 4b964a9628a6d30d33b2cb509f4a50eccf6a8c8e Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:13:53 -0700 Subject: [PATCH 06/44] Require exact D2 capability and mount profiles --- agents/container/profile.ts | 41 ++++++++++++++++++++++++++++++------ agents/container/run.ts | 34 ++++++++++++++++++------------ test/agent-container.test.ts | 36 +++++++++++++++++++++++++------ 3 files changed, 84 insertions(+), 27 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 3df0e75..fbca90e 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,5 +1,8 @@ import { createHash } from 'node:crypto'; -import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, + readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { InvocationInput, Phase } from '../contract.ts'; export interface TaskFilesystems { @@ -42,10 +45,11 @@ interface FileIdentity { readonly mtimeMs: number; readonly digest: string; } -interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity } +interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity; + readonly cleanupDirectory?: string } const identities = new WeakMap(); -const captureFile = (path: string, kind: string): FileIdentity => { +const readCapturedFile = (path: string, kind: string): { identity: FileIdentity; content: Buffer } => { let fd: number | undefined; try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); @@ -57,10 +61,12 @@ const captureFile = (path: string, kind: string): FileIdentity => { if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error(`${kind} changed while its identity was captured.`); - return Object.freeze({ path, dev: after.dev, ino: after.ino, mode: after.mode, nlink: after.nlink, + const identity = Object.freeze({ path, dev: after.dev, ino: after.ino, mode: after.mode, nlink: after.nlink, size: after.size, mtimeMs: after.mtimeMs, digest: createHash('sha256').update(content).digest('hex') }); + return { identity, content }; } finally { if (fd !== undefined) closeSync(fd); } }; +const captureFile = (path: string, kind: string) => readCapturedFile(path, kind).identity; const sameFile = (actual: FileIdentity, expected: FileIdentity) => actual.path === expected.path && actual.dev === expected.dev && actual.ino === expected.ino && actual.mode === expected.mode && actual.nlink === expected.nlink && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs @@ -91,6 +97,14 @@ export function assertContainerProfile(profile: ContainerProfile): void { } } +/** Remove runner-owned credential staging after this one-shot profile settles. */ +export function disposeContainerProfile(profile: ContainerProfile): void { + const identity = identities.get(profile); + if (!identity) return; + identities.delete(profile); + if (identity.cleanupDirectory) rmSync(identity.cleanupDirectory, { recursive: true, force: true }); +} + const safeName = (value: string) => { const prefix = value.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 24); return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`; @@ -120,8 +134,21 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil || !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.'); if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) throw new Error('Codex auth must be a direct regular file, not a link.'); - const codexAuthFile = options.codexAuthFile ? mountSource(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; - const authIdentity = codexAuthFile ? captureFile(codexAuthFile, 'Codex auth') : undefined; + const sourceAuth = options.codexAuthFile ? readCapturedFile(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; + let cleanupDirectory: string | undefined, codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined; + if (sourceAuth) { + cleanupDirectory = mkdtempSync(join(tmpdir(), 'codeboost-auth-')); + try { + const stagedAuth = join(cleanupDirectory, 'auth.json'); + writeFileSync(stagedAuth, sourceAuth.content, { mode: 0o400, flag: 'wx' }); + chmodSync(stagedAuth, 0o444); + codexAuthFile = mountSource(realpathSync(stagedAuth), 'Codex auth'); + authIdentity = captureFile(codexAuthFile, 'Staged Codex auth'); + } catch (error) { + rmSync(cleanupDirectory, { recursive: true, force: true }); + throw error; + } + } const name = `codeboost-agent-${safeName(invocation.attemptId)}`; const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', @@ -147,6 +174,6 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory, codexAuthFile, command: Object.freeze([...options.command]) }); - identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity })); + identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity, cleanupDirectory })); return profile; } diff --git a/agents/container/run.ts b/agents/container/run.ts index 709c902..42b600a 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -1,7 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; -import { assertContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; +import { assertContainerProfile, disposeContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ @@ -32,6 +32,11 @@ const createDeadline = (timeoutMs: number) => { const resourceName = (kind: string) => `codeboost-${kind}-${randomUUID()}`; const exactNoNewPrivileges = (options: string[] | null | undefined) => options?.length === 1 && (options[0] === 'no-new-privileges' || options[0] === 'no-new-privileges:true'); +export const hasExactOptions = (value: string | undefined, expected: readonly string[]) => { + const parts = value?.split(',') ?? []; + return parts.length === expected.length && new Set(parts).size === parts.length + && expected.every(option => parts.includes(option)); +}; const canonicalDockerBindSource = (source: string) => { const desktopHostPath = source.startsWith('/host_mnt/') ? source.slice('/host_mnt'.length) : source; try { return realpathSync(desktopHostPath); } catch { return source; } @@ -98,6 +103,7 @@ type Inspect = { Image: string; Config: { Image: string; User: string; Env: string[]; Entrypoint: string[] | null; Cmd: string[] | null; WorkingDir: string }; HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; + CapAdd: string[] | null; NetworkMode: string; PidMode: string; IpcMode: string; PidsLimit: number; Memory: number; NanoCpus: number; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; @@ -127,7 +133,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || JSON.stringify(inspect.Config.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) || JSON.stringify(inspect.Config.Cmd) !== JSON.stringify(profile.command) || !host.ReadonlyRootfs || host.Privileged - || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0 || !exactNoNewPrivileges(host.SecurityOpt) || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 @@ -142,8 +148,7 @@ export function validateContainer(container: string, profile: ContainerProfile, ]); if (Object.keys(tmpfs).length !== expectedTmpfs.size) throw new Error('Container tmpfs mount set changed.'); for (const [path, expected] of expectedTmpfs) { - const actual = new Set((tmpfs[path] ?? '').split(',')); - if (expected.some(option => !actual.has(option))) throw new Error(`Container tmpfs ${path} is missing required options.`); + if (!hasExactOptions(tmpfs[path], expected)) throw new Error(`Container tmpfs ${path} options changed.`); } const mounts = new Map(inspect.Mounts.map(item => [item.Destination, item])); const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', @@ -171,22 +176,21 @@ export function validateContainer(container: string, profile: ContainerProfile, const expected = expectedVolumes.get(volume.Name), options = volume.Options ?? {}, optionString = options.o ?? ''; if (!expected || volume.Driver !== 'local' || options.type !== 'tmpfs' || options.device !== 'tmpfs' || volume.Labels?.['io.codeboost.task-storage'] !== expected[0] - || !optionString.split(',').includes(`size=${expected[1]}`) - || !optionString.split(',').includes(`nr_inodes=${expected[2]}`) - || !optionString.split(',').includes('uid=10001') || !optionString.split(',').includes('gid=10001') - || !optionString.split(',').includes('mode=0755') || !optionString.split(',').includes('nosuid') - || !optionString.split(',').includes('nodev')) + || !hasExactOptions(optionString, [`size=${expected[1]}`, `nr_inodes=${expected[2]}`, + 'uid=10001', 'gid=10001', 'mode=0755', 'nosuid', 'nodev'])) throw new Error('Task volume does not match its bounded tmpfs allocation.'); } const keeper = JSON.parse(docker(['container', 'inspect', profile.filesystems.keeper], { timeoutMs: remaining() }))[0] as { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record }; HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; NetworkMode?: string; CapDrop?: string[] | null; - SecurityOpt?: string[] | null }; Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; + CapAdd?: string[] | null; SecurityOpt?: string[] | null }; + Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); if (!keeper?.State?.Running || keeper.Config?.Image !== profile.expectedImage || keeper.Config?.User !== '10001:10001' || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || (keeper.HostConfig.CapAdd?.length ?? 0) !== 0 || !exactNoNewPrivileges(keeper.HostConfig.SecurityOpt) || keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume || keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume) @@ -225,9 +229,9 @@ export function validateContainer(container: string, profile: ContainerProfile, export function createValidatedContainer(profile: ContainerProfile, timeoutMs = 30_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); - validateSecrets(profile, secrets); - assertContainerProfile(profile); try { + validateSecrets(profile, secrets); + assertContainerProfile(profile); docker(profile.args, { timeoutMs: remaining(), secrets }); validateContainer(profile.name, profile, remaining()); assertContainerProfile(profile); @@ -235,6 +239,7 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs = return profile.name; } catch (error) { spawnSync('docker', ['rm', '--force', profile.name], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); + disposeContainerProfile(profile); throw error; } } @@ -249,7 +254,10 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, remaining(); return output; } - finally { spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); } + finally { + spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); + disposeContainerProfile(profile); + } } export function removeTaskFilesystems(filesystems: TaskFilesystems): void { diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index ef17210..df12caa 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -1,19 +1,20 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; import { AGENT_IMAGE, buildAgentImage } from '../agents/container/image.ts'; -import { createContainerProfile } from '../agents/container/profile.ts'; +import { createContainerProfile, disposeContainerProfile } from '../agents/container/profile.ts'; import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, - validateContainer } from '../agents/container/run.ts'; + hasExactOptions, validateContainer } from '../agents/container/run.ts'; import { createTaskClone } from '../git/clone.ts'; const roots: string[] = []; const taskFilesystems: ReturnType[] = []; const containers = new Set(); +const profiles: ReturnType[] = []; let imageId = ''; const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); @@ -54,6 +55,7 @@ function profile(data: ReturnType, phase: Phase, command: string imageId, codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); + profiles.push(base); return base; } @@ -61,6 +63,7 @@ beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000); afterAll(() => { for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); + for (const profile of profiles) disposeContainerProfile(profile); for (const root of roots.reverse()) { chmodSync(join(root, 'input'), 0o700); rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); @@ -180,13 +183,15 @@ describe('real Docker agent isolation', () => { ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); + writeFileSync(data.fakeAuth, '{"changed":true}'); + expect(valid.codexAuthFile).not.toBe(data.fakeAuth); + expect(readFileSync(valid.codexAuthFile!, 'utf8')).toBe('{}'); + expect(statSync(valid.codexAuthFile!).mode & 0o777).toBe(0o444); + writeFileSync(data.fakeAuth, '{}'); + chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => createValidatedContainer(valid)).toThrow('only one bounded'); chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); - - writeFileSync(data.fakeAuth, '{"changed":true}'); - expect(() => createValidatedContainer(valid)).toThrow('Codex auth changed'); - writeFileSync(data.fakeAuth, '{}'); }); it('rejects extra security policies and a PATH that can shadow the startup probe', () => { @@ -204,6 +209,23 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); + it('rejects added capabilities and conflicting or duplicate filesystem options', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const imageIndex = valid.args.indexOf(imageId); + const args = [...valid.args.slice(0, imageIndex), '--cap-add=SYS_ADMIN', ...valid.args.slice(imageIndex)]; + docker(...args); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + const state = JSON.parse(docker('container', 'inspect', valid.name))[0] as { State: { Status: string } }; + expect(state.State.Status).toBe('created'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + + const expected = ['size=1024', 'nr_inodes=16', 'uid=10001', 'gid=10001', 'mode=0755', 'nosuid', 'nodev']; + expect(hasExactOptions(expected.join(','), expected)).toBe(true); + expect(hasExactOptions([...expected, 'size=2048'].join(','), expected)).toBe(false); + expect(hasExactOptions([...expected, 'dev'].join(','), expected)).toBe(false); + expect(hasExactOptions([...expected, 'nosuid'].join(','), expected)).toBe(false); + }, 60_000); + it('rejects a caller-mutated network before the container can start', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const args = valid.args.map(value => value === '--network=none' ? '--network=bridge' : value); From 0845b6d0a79d5649e5b740257b77b7ca421908ca Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:24:03 -0700 Subject: [PATCH 07/44] Trust D2 helper images and settle cleanup --- agents/container/image.ts | 6 ++++++ agents/container/profile.ts | 2 ++ agents/container/run.ts | 33 +++++++++++++++++++++++++++------ test/agent-container.test.ts | 16 ++++++++++++++-- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/agents/container/image.ts b/agents/container/image.ts index 876a029..cdfc655 100644 --- a/agents/container/image.ts +++ b/agents/container/image.ts @@ -8,6 +8,11 @@ export const CODEX_VERSION = '0.153.4'; export const CLAUDE_VERSION = '2.1.281'; const context = dirname(fileURLToPath(import.meta.url)); +const trustedImages = new Set(); + +export function assertBuiltAgentImage(imageId: string): void { + if (!trustedImages.has(imageId)) throw new Error('Agent image was not produced by the trusted validated builder.'); +} export function buildAgentImage(timeoutMs = 10 * 60_000): string { if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Image build requires a finite positive deadline.'); @@ -30,5 +35,6 @@ export function buildAgentImage(timeoutMs = 10 * 60_000): string { || labels['io.codeboost.codex.version'] !== CODEX_VERSION || labels['io.codeboost.claude.version'] !== CLAUDE_VERSION || labels['io.codeboost.profile.version'] !== '1') throw new Error('Built agent image does not match the pinned profile.'); + trustedImages.add(inspect.Id); return inspect.Id; } diff --git a/agents/container/profile.ts b/agents/container/profile.ts index fbca90e..706cfe2 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -4,6 +4,7 @@ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, ope import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { InvocationInput, Phase } from '../contract.ts'; +import { assertBuiltAgentImage } from './image.ts'; export interface TaskFilesystems { readonly keeper: string; @@ -122,6 +123,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container command must be a complete literal argv array.'); if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) throw new Error('Container profile requires the immutable built image ID.'); + assertBuiltAgentImage(options.imageId); const inputIdentity = captureInput(options.inputDirectory); const inputDirectory = inputIdentity.inputDirectory; if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) diff --git a/agents/container/run.ts b/agents/container/run.ts index 42b600a..5c96dda 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; import { assertContainerProfile, disposeContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; -import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; +import { assertBuiltAgentImage, BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, ...secrets, @@ -41,6 +41,19 @@ const canonicalDockerBindSource = (source: string) => { const desktopHostPath = source.startsWith('/host_mnt/') ? source.slice('/host_mnt'.length) : source; try { return realpathSync(desktopHostPath); } catch { return source; } }; +const removeContainerOrThrow = (profile: ContainerProfile) => { + const result = spawnSync('docker', ['rm', '--force', profile.name], { + encoding: 'utf8', timeout: 30_000, env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + const inspect = spawnSync('docker', ['container', 'inspect', profile.name], { + encoding: 'utf8', timeout: 30_000, env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + }); + const absent = inspect.status !== 0 && !inspect.error && /No such (?:object|container)/i.test(inspect.stderr ?? ''); + if (!absent) throw new Error('Failed to confirm removal of the agent container; staged credentials were retained.'); + } + disposeContainerProfile(profile); +}; export interface TaskStorageLimits { readonly workBytes: number; @@ -54,6 +67,7 @@ export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskSto imageId: string, timeoutMs = 60_000): TaskFilesystems { for (const [name, value] of Object.entries(limits)) validLimit(value, name); if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); + assertBuiltAgentImage(imageId); const remaining = createDeadline(timeoutMs); const staging = realpathSync(stagingDirectory); if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); @@ -217,7 +231,9 @@ export function validateContainer(container: string, profile: ContainerProfile, || environment.get('CODEBOOST_WORK_BYTES') !== String(profile.filesystems.workBytes) || environment.get('CODEBOOST_WORK_INODES') !== String(profile.filesystems.workInodes) || environment.get('CODEBOOST_METADATA_BYTES') !== String(profile.filesystems.metadataBytes) - || environment.get('CODEBOOST_METADATA_INODES') !== String(profile.filesystems.metadataInodes)) + || environment.get('CODEBOOST_METADATA_INODES') !== String(profile.filesystems.metadataInodes) + || environment.get('npm_config_cache') !== '/tmp/npm-cache' + || environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache') throw new Error('Container isolation environment changed.'); if (profile.vendor === 'codex' && names.includes('CLAUDE_CODE_OAUTH_TOKEN')) throw new Error('Credential profiles must not be combined.'); if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) @@ -238,8 +254,8 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs = remaining(); return profile.name; } catch (error) { - spawnSync('docker', ['rm', '--force', profile.name], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); - disposeContainerProfile(profile); + try { removeContainerOrThrow(profile); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Container creation failed and cleanup did not settle.'); } throw error; } } @@ -248,15 +264,20 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); const container = createValidatedContainer(profile, remaining(), secrets); + let failure: unknown; try { assertContainerProfile(profile); const output = docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); remaining(); return output; } + catch (error) { failure = error; throw error; } finally { - spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); - disposeContainerProfile(profile); + try { removeContainerOrThrow(profile); } + catch (cleanupError) { + if (failure) throw new AggregateError([failure, cleanupError], 'Agent invocation failed and cleanup did not settle.'); + throw cleanupError; + } } } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index df12caa..e5bd020 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; -import { AGENT_IMAGE, buildAgentImage } from '../agents/container/image.ts'; +import { AGENT_IMAGE, assertBuiltAgentImage, buildAgentImage } from '../agents/container/image.ts'; import { createContainerProfile, disposeContainerProfile } from '../agents/container/profile.ts'; import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, hasExactOptions, validateContainer } from '../agents/container/run.ts'; @@ -194,7 +194,7 @@ describe('real Docker agent isolation', () => { chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); }); - it('rejects extra security policies and a PATH that can shadow the startup probe', () => { + it('rejects extra security policies and environment paths that can escape bounded storage', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const imageIndex = valid.args.indexOf(imageId); const securityArgs = [...valid.args.slice(0, imageIndex), '--security-opt', 'seccomp=unconfined', @@ -207,6 +207,13 @@ describe('real Docker agent isolation', () => { docker(...pathArgs); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow(/environment|PATH/); docker('rm', '--force', valid.name); containers.delete(valid.name); + + for (const changedCache of ['npm_config_cache=/work/npm-cache', 'XDG_CACHE_HOME=/work/xdg-cache']) { + const cacheArgs = [...valid.args.slice(0, imageIndex), '--env', changedCache, ...valid.args.slice(imageIndex)]; + docker(...cacheArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('isolation environment'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + } }, 60_000); it('rejects added capabilities and conflicting or duplicate filesystem options', () => { @@ -241,6 +248,11 @@ describe('real Docker agent isolation', () => { expect(valid.expectedImage).toBe(imageId); expect(valid.args).toContain(imageId); expect(valid.args).not.toContain(AGENT_IMAGE); + const untrustedDigest = `sha256:${'0'.repeat(64)}`; + expect(() => assertBuiltAgentImage(untrustedDigest)).toThrow('trusted validated builder'); + expect(() => prepareTaskFilesystems(data.clone.directory, { + workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, + }, untrustedDigest)).toThrow('trusted validated builder'); expect(() => prepareTaskFilesystems(data.clone.directory, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, AGENT_IMAGE)).toThrow('immutable built image ID'); From 5ccd2aa3e310a9d82d021538ae8820b4ec5b56f2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:37:31 -0700 Subject: [PATCH 08/44] Bind D2 resources to their owners --- agents/container/profile.ts | 31 ++++---- agents/container/run.ts | 106 ++++++++----------------- agents/container/storage.ts | 145 +++++++++++++++++++++++++++++++++++ test/agent-container.test.ts | 31 +++++++- 4 files changed, 218 insertions(+), 95 deletions(-) create mode 100644 agents/container/storage.ts diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 706cfe2..d6a5d5f 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,20 +1,11 @@ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { InvocationInput, Phase } from '../contract.ts'; import { assertBuiltAgentImage } from './image.ts'; - -export interface TaskFilesystems { - readonly keeper: string; - readonly workVolume: string; - readonly metadataVolume: string; - readonly workBytes: number; - readonly workInodes: number; - readonly metadataBytes: number; - readonly metadataInodes: number; -} +import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts'; export interface ContainerProfile { readonly name: string; readonly args: readonly string[]; @@ -25,6 +16,7 @@ export interface ContainerProfile { readonly inputDirectory: string; readonly codexAuthFile?: string; readonly command: readonly string[]; + readonly ownershipId: string; } export interface ProfileOptions { readonly invocation: InvocationInput; @@ -47,7 +39,8 @@ interface FileIdentity { readonly digest: string; } interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity; - readonly cleanupDirectory?: string } + readonly cleanupDirectory?: string; readonly filesystems: TaskFilesystems; readonly clone: InvocationInput['clone'] } +type InputIdentity = Pick; const identities = new WeakMap(); const readCapturedFile = (path: string, kind: string): { identity: FileIdentity; content: Buffer } => { @@ -72,7 +65,7 @@ const sameFile = (actual: FileIdentity, expected: FileIdentity) => actual.path = && actual.dev === expected.dev && actual.ino === expected.ino && actual.mode === expected.mode && actual.nlink === expected.nlink && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs && actual.digest === expected.digest; -const captureInput = (directory: string): ProfileIdentity => { +const captureInput = (directory: string): InputIdentity => { const stat = lstatSync(directory); if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be a container-readable real directory.'); @@ -89,6 +82,7 @@ const captureInput = (directory: string): ProfileIdentity => { export function assertContainerProfile(profile: ContainerProfile): void { const expected = identities.get(profile); if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); + assertTaskFilesystems(expected.filesystems, expected.clone); const actual = captureInput(expected.inputDirectory); if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) throw new Error('Schema input changed after the profile was captured.'); @@ -124,6 +118,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) throw new Error('Container profile requires the immutable built image ID.'); assertBuiltAgentImage(options.imageId); + assertTaskFilesystems(filesystems, invocation.clone); const inputIdentity = captureInput(options.inputDirectory); const inputDirectory = inputIdentity.inputDirectory; if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) @@ -151,11 +146,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw error; } } - const name = `codeboost-agent-${safeName(invocation.attemptId)}`; + const name = `codeboost-agent-${safeName(invocation.attemptId)}`, ownershipId = randomUUID(); const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1', '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + '--label', `io.codeboost.invocation=${ownershipId}`, '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, @@ -171,11 +167,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); args.push(options.imageId, ...options.command); - const capturedFilesystems = Object.freeze({ ...filesystems }); + const capturedFilesystems = filesystems; const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory, codexAuthFile, - command: Object.freeze([...options.command]) }); - identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity, cleanupDirectory })); + command: Object.freeze([...options.command]), ownershipId }); + identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity, cleanupDirectory, + filesystems, clone: invocation.clone })); return profile; } diff --git a/agents/container/run.ts b/agents/container/run.ts index 5c96dda..7ae6fb8 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -1,8 +1,10 @@ import { execFileSync, spawnSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { lstatSync, realpathSync } from 'node:fs'; -import { assertContainerProfile, disposeContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; -import { assertBuiltAgentImage, BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; +import { realpathSync } from 'node:fs'; +import { assertContainerProfile, disposeContainerProfile, type ContainerProfile } from './profile.ts'; +import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; +import { taskFilesystemAllocationId } from './storage.ts'; +export { prepareTaskFilesystems, removeTaskFilesystems } from './storage.ts'; +export type { TaskFilesystems, TaskStorageLimits } from './storage.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, ...secrets, @@ -29,7 +31,6 @@ const createDeadline = (timeoutMs: number) => { return value; }; }; -const resourceName = (kind: string) => `codeboost-${kind}-${randomUUID()}`; const exactNoNewPrivileges = (options: string[] | null | undefined) => options?.length === 1 && (options[0] === 'no-new-privileges' || options[0] === 'no-new-privileges:true'); export const hasExactOptions = (value: string | undefined, expected: readonly string[]) => { @@ -42,80 +43,39 @@ const canonicalDockerBindSource = (source: string) => { try { return realpathSync(desktopHostPath); } catch { return source; } }; const removeContainerOrThrow = (profile: ContainerProfile) => { + const remaining = createDeadline(30_000); + const before = spawnSync('docker', ['container', 'inspect', profile.name], { + encoding: 'utf8', timeout: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + }); + if (before.status !== 0) { + const missing = !before.error && /No such (?:object|container)/i.test(`${before.stdout ?? ''}\n${before.stderr ?? ''}`); + if (!missing) throw new Error('Failed to establish ownership of the agent container; staged credentials were retained.'); + disposeContainerProfile(profile); + return; + } + const inspected = JSON.parse(before.stdout || '[]')[0] as { Config?: { Labels?: Record } } | undefined; + if (inspected?.Config?.Labels?.['io.codeboost.invocation'] !== profile.ownershipId) { + disposeContainerProfile(profile); + return; + } const result = spawnSync('docker', ['rm', '--force', profile.name], { - encoding: 'utf8', timeout: 30_000, env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', timeout: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], }); if (result.status !== 0) { const inspect = spawnSync('docker', ['container', 'inspect', profile.name], { - encoding: 'utf8', timeout: 30_000, env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', timeout: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], }); - const absent = inspect.status !== 0 && !inspect.error && /No such (?:object|container)/i.test(inspect.stderr ?? ''); + const absent = inspect.status !== 0 && !inspect.error + && /No such (?:object|container)/i.test(`${inspect.stdout ?? ''}\n${inspect.stderr ?? ''}`); if (!absent) throw new Error('Failed to confirm removal of the agent container; staged credentials were retained.'); } disposeContainerProfile(profile); }; -export interface TaskStorageLimits { - readonly workBytes: number; - readonly workInodes: number; - readonly metadataBytes: number; - readonly metadataInodes: number; -} - -/** Allocate bounded, engine-owned task filesystems and keep them mounted. */ -export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskStorageLimits, - imageId: string, timeoutMs = 60_000): TaskFilesystems { - for (const [name, value] of Object.entries(limits)) validLimit(value, name); - if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); - assertBuiltAgentImage(imageId); - const remaining = createDeadline(timeoutMs); - const staging = realpathSync(stagingDirectory); - if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); - if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); - const workVolume = resourceName('work'), metadataVolume = resourceName('metadata'), keeper = resourceName('keeper'); - const createdVolumes: string[] = []; - try { - for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes], - ['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) { - docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', - '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, - '--label', `io.codeboost.task-storage=${kind}`, name], { timeoutMs: remaining() }); - createdVolumes.push(name); - } - const seed = [ - 'set -eu', - 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/', - 'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/', - 'rm -rf /work/.git', - 'mkdir /work/.git', - 'chown -R 10001:10001 /work /metadata', - ].join('; '); - docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', - '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', - '--mount', `type=volume,source=${workVolume},target=/work`, - '--mount', `type=volume,source=${metadataVolume},target=/metadata`, - '--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sleep', imageId, 'infinity'], - { timeoutMs: remaining() }); - docker(['run', '--rm', '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', - '--cap-add=CHOWN', '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32', - '--memory=128m', '--cpus=.25', - '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, - '--mount', `type=volume,source=${workVolume},target=/work`, - '--mount', `type=volume,source=${metadataVolume},target=/metadata`, - '--entrypoint', 'sh', imageId, '-c', seed], { timeoutMs: remaining() }); - remaining(); - return Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); - } catch (error) { - spawnSync('docker', ['rm', '--force', keeper], { env: dockerEnvironment(), stdio: 'ignore' }); - for (const volume of createdVolumes.reverse()) - spawnSync('docker', ['volume', 'rm', '--force', volume], { env: dockerEnvironment(), stdio: 'ignore' }); - throw error; - } -} - type Inspect = { Image: string; - Config: { Image: string; User: string; Env: string[]; Entrypoint: string[] | null; Cmd: string[] | null; WorkingDir: string }; + Config: { Image: string; User: string; Env: string[]; Entrypoint: string[] | null; Cmd: string[] | null; + WorkingDir: string; Labels: Record | null }; HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; CapAdd: string[] | null; NetworkMode: string; PidMode: string; IpcMode: string; PidsLimit: number; Memory: number; NanoCpus: number; @@ -146,6 +106,7 @@ export function validateContainer(container: string, profile: ContainerProfile, if (inspect.Config.User !== '10001:10001' || inspect.Config.WorkingDir !== '/work' || JSON.stringify(inspect.Config.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) || JSON.stringify(inspect.Config.Cmd) !== JSON.stringify(profile.command) + || inspect.Config.Labels?.['io.codeboost.invocation'] !== profile.ownershipId || !host.ReadonlyRootfs || host.Privileged || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0 || !exactNoNewPrivileges(host.SecurityOpt) @@ -182,6 +143,7 @@ export function validateContainer(container: string, profile: ContainerProfile, if (work.Source === metadata.Source) throw new Error('Worktree and Git metadata must use separate filesystems.'); const volumes = JSON.parse(docker(['volume', 'inspect', work.Name!, metadata.Name!], { timeoutMs: remaining() })) as Array<{ Name: string; Driver: string; Labels: Record | null; Options: Record | null }>; + const allocationId = taskFilesystemAllocationId(profile.filesystems); const expectedVolumes = new Map([ [work.Name!, ['work', String(profile.filesystems.workBytes), String(profile.filesystems.workInodes)]], [metadata.Name!, ['metadata', String(profile.filesystems.metadataBytes), String(profile.filesystems.metadataInodes)]], @@ -190,6 +152,7 @@ export function validateContainer(container: string, profile: ContainerProfile, const expected = expectedVolumes.get(volume.Name), options = volume.Options ?? {}, optionString = options.o ?? ''; if (!expected || volume.Driver !== 'local' || options.type !== 'tmpfs' || options.device !== 'tmpfs' || volume.Labels?.['io.codeboost.task-storage'] !== expected[0] + || volume.Labels?.['io.codeboost.allocation'] !== allocationId || !hasExactOptions(optionString, [`size=${expected[1]}`, `nr_inodes=${expected[2]}`, 'uid=10001', 'gid=10001', 'mode=0755', 'nosuid', 'nodev'])) throw new Error('Task volume does not match its bounded tmpfs allocation.'); @@ -201,7 +164,8 @@ export function validateContainer(container: string, profile: ContainerProfile, Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); if (!keeper?.State?.Running || keeper.Config?.Image !== profile.expectedImage || keeper.Config?.User !== '10001:10001' - || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs + || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' + || keeper.Config?.Labels?.['io.codeboost.allocation'] !== allocationId || !keeper.HostConfig?.ReadonlyRootfs || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (keeper.HostConfig.CapAdd?.length ?? 0) !== 0 @@ -280,9 +244,3 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, } } } - -export function removeTaskFilesystems(filesystems: TaskFilesystems): void { - spawnSync('docker', ['rm', '--force', filesystems.keeper], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); - for (const volume of [filesystems.metadataVolume, filesystems.workVolume]) - spawnSync('docker', ['volume', 'rm', '--force', volume], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); -} diff --git a/agents/container/storage.ts b/agents/container/storage.ts new file mode 100644 index 0000000..7e5e8fe --- /dev/null +++ b/agents/container/storage.ts @@ -0,0 +1,145 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstatSync, realpathSync } from 'node:fs'; +import type { TaskClone } from '../contract.ts'; +import { assertBuiltAgentImage } from './image.ts'; + +export interface TaskFilesystems { + readonly keeper: string; + readonly workVolume: string; + readonly metadataVolume: string; + readonly workBytes: number; + readonly workInodes: number; + readonly metadataBytes: number; + readonly metadataInodes: number; +} +export interface TaskStorageLimits { + readonly workBytes: number; + readonly workInodes: number; + readonly metadataBytes: number; + readonly metadataInodes: number; +} + +interface AllocationIdentity { + readonly allocationId: string; + readonly clone: Readonly; + readonly limits: Readonly; +} +const allocations = new WeakMap(); +const dockerEnvironment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); +const validLimit = (value: number, name: string) => { + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`); +}; +const createDeadline = (timeoutMs: number) => { + validLimit(timeoutMs, 'timeoutMs'); + const deadline = performance.now() + timeoutMs; + return () => { + const value = Math.ceil(deadline - performance.now()); + if (value <= 0) throw new Error('Docker operation exceeded its overall deadline.'); + return value; + }; +}; +const docker = (args: readonly string[], timeoutMs: number) => execFileSync('docker', [...args], { + encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', env: dockerEnvironment(), + stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); +const absent = (result: ReturnType) => result.status !== 0 && !result.error + && /No such (?:object|container|volume)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); +const remove = (args: readonly string[], inspectArgs: readonly string[], remaining: () => number, kind: string, + allocationId: string) => { + const before = spawnSync('docker', [...inspectArgs], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (before.status !== 0) { + if (absent(before)) return; + throw new Error(`Failed to establish ownership of ${kind}.`); + } + const inspected = JSON.parse(before.stdout || '[]')[0] as + { Labels?: Record; Config?: { Labels?: Record } } | undefined; + const labels = inspected?.Labels ?? inspected?.Config?.Labels; + if (labels?.['io.codeboost.allocation'] !== allocationId) throw new Error(`Refused to remove unowned ${kind}.`); + const result = spawnSync('docker', [...args], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.status === 0) return; + const inspect = spawnSync('docker', [...inspectArgs], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (!absent(inspect)) throw new Error(`Failed to confirm removal of ${kind}.`); +}; +const cleanup = (keeper: string, volumes: readonly string[], allocationId: string, timeoutMs = 30_000) => { + const remaining = createDeadline(timeoutMs), failures: unknown[] = []; + try { remove(['rm', '--force', keeper], ['container', 'inspect', keeper], remaining, 'task keeper', allocationId); } + catch (error) { failures.push(error); } + for (const volume of volumes) { + try { remove(['volume', 'rm', '--force', volume], ['volume', 'inspect', volume], remaining, 'task volume', allocationId); } + catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, 'Task filesystem cleanup did not settle.'); +}; + +export function assertTaskFilesystems(filesystems: TaskFilesystems, clone?: TaskClone): void { + const identity = allocations.get(filesystems); + if (!identity) throw new Error('Task filesystems were not created by the trusted allocator.'); + const { limits } = identity; + if (filesystems.workBytes !== limits.workBytes || filesystems.workInodes !== limits.workInodes + || filesystems.metadataBytes !== limits.metadataBytes || filesystems.metadataInodes !== limits.metadataInodes) + throw new Error('Task filesystem limits changed after allocation.'); + if (clone && (clone.id !== identity.clone.id || clone.taskId !== identity.clone.taskId + || realpathSync(clone.directory) !== identity.clone.directory || clone.head !== identity.clone.head)) + throw new Error('Task filesystems do not belong to the invocation clone.'); +} + +export function taskFilesystemAllocationId(filesystems: TaskFilesystems): string { + assertTaskFilesystems(filesystems); + return allocations.get(filesystems)!.allocationId; +} + +/** Allocate bounded, engine-owned task filesystems and keep them mounted. */ +export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimits, + imageId: string, timeoutMs = 60_000): TaskFilesystems { + for (const [name, value] of Object.entries(limits)) validLimit(value, name); + if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); + assertBuiltAgentImage(imageId); + const remaining = createDeadline(timeoutMs), staging = realpathSync(clone.directory); + if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); + if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); + const allocationId = randomUUID(); + const workVolume = `codeboost-work-${randomUUID()}`, metadataVolume = `codeboost-metadata-${randomUUID()}`; + const keeper = `codeboost-keeper-${randomUUID()}`, createdVolumes: string[] = []; + try { + for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes], + ['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) { + docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', + '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, + '--label', `io.codeboost.task-storage=${kind}`, '--label', `io.codeboost.allocation=${allocationId}`, name], remaining()); + createdVolumes.push(name); + } + const seed = ['set -eu', 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/', + 'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/', 'rm -rf /work/.git', 'mkdir /work/.git', + 'chown -R 10001:10001 /work /metadata'].join('; '); + docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', + '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, + '--label', 'io.codeboost.task-storage=keeper', '--label', `io.codeboost.allocation=${allocationId}`, + '--entrypoint', 'sleep', imageId, 'infinity'], remaining()); + docker(['run', '--rm', '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN', + '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32', + '--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, + '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, + '--entrypoint', 'sh', imageId, '-c', seed], remaining()); + remaining(); + const filesystems = Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); + allocations.set(filesystems, Object.freeze({ allocationId, + clone: Object.freeze({ ...clone, directory: staging }), limits: Object.freeze({ ...limits }) })); + return filesystems; + } catch (error) { + try { cleanup(keeper, createdVolumes.reverse(), allocationId); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Task allocation failed and cleanup did not settle.'); } + throw error; + } +} + +export function removeTaskFilesystems(filesystems: TaskFilesystems): void { + assertTaskFilesystems(filesystems); + const allocationId = taskFilesystemAllocationId(filesystems); + cleanup(filesystems.keeper, [filesystems.metadataVolume, filesystems.workVolume], allocationId); + allocations.delete(filesystems); +} diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index e5bd020..a006bd5 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -31,7 +31,7 @@ function fixture() { writeFileSync(join(input, 'schema.json'), '{"probe":"codeboost-schema-marker"}\n'); chmodSync(join(input, 'schema.json'), 0o444); chmodSync(input, 0o555); const clone = createTaskClone({ source, parent: staging, taskId: 'task-1', head: git(source, 'rev-parse', 'HEAD') }); - const filesystems = prepareTaskFilesystems(clone.directory, { + const filesystems = prepareTaskFilesystems(clone, { workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, }, imageId); taskFilesystems.push(filesystems); @@ -68,7 +68,7 @@ afterAll(() => { chmodSync(join(root, 'input'), 0o700); rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } -}); +}, 120_000); describe('real Docker agent isolation', () => { it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { @@ -183,6 +183,15 @@ describe('real Docker agent isolation', () => { ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + filesystems: { ...data.filesystems }, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, + imageId })).toThrow('trusted allocator'); + + const other = fixture(); + expect(() => createContainerProfile({ invocation: invocation(other.clone, 'planning'), + filesystems: data.filesystems, inputDirectory: other.input, command: ['true'], codexAuthFile: other.fakeAuth, + imageId })).toThrow('do not belong to the invocation clone'); + writeFileSync(data.fakeAuth, '{"changed":true}'); expect(valid.codexAuthFile).not.toBe(data.fakeAuth); expect(readFileSync(valid.codexAuthFile!, 'utf8')).toBe('{}'); @@ -216,6 +225,20 @@ describe('real Docker agent isolation', () => { } }, 60_000); + it('does not remove an active container when a duplicate attempt name collides', () => { + const data = fixture(), captured = invocation(data.clone, 'planning'); + const first = createContainerProfile({ invocation: captured, filesystems: data.filesystems, + inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId }); + const duplicate = createContainerProfile({ invocation: captured, filesystems: data.filesystems, + inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId }); + profiles.push(first, duplicate); + docker(...first.args); containers.add(first.name); + expect(() => createValidatedContainer(duplicate)).toThrow(); + const state = JSON.parse(docker('container', 'inspect', first.name))[0] as { State: { Status: string } }; + expect(state.State.Status).toBe('created'); + docker('rm', '--force', first.name); containers.delete(first.name); + }, 60_000); + it('rejects added capabilities and conflicting or duplicate filesystem options', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const imageIndex = valid.args.indexOf(imageId); @@ -250,10 +273,10 @@ describe('real Docker agent isolation', () => { expect(valid.args).not.toContain(AGENT_IMAGE); const untrustedDigest = `sha256:${'0'.repeat(64)}`; expect(() => assertBuiltAgentImage(untrustedDigest)).toThrow('trusted validated builder'); - expect(() => prepareTaskFilesystems(data.clone.directory, { + expect(() => prepareTaskFilesystems(data.clone, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, untrustedDigest)).toThrow('trusted validated builder'); - expect(() => prepareTaskFilesystems(data.clone.directory, { + expect(() => prepareTaskFilesystems(data.clone, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, AGENT_IMAGE)).toThrow('immutable built image ID'); }); From b975c7f2ae40f3a97af26ac07fc0fb90e5bff8a2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:44:24 -0700 Subject: [PATCH 09/44] Close D2 namespace and cleanup gaps --- agents/container/profile.ts | 2 +- agents/container/run.ts | 8 ++++++-- agents/container/storage.ts | 21 +++++++++++++-------- test/agent-container.test.ts | 6 ++++++ 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index d6a5d5f..a48da4b 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -96,8 +96,8 @@ export function assertContainerProfile(profile: ContainerProfile): void { export function disposeContainerProfile(profile: ContainerProfile): void { const identity = identities.get(profile); if (!identity) return; - identities.delete(profile); if (identity.cleanupDirectory) rmSync(identity.cleanupDirectory, { recursive: true, force: true }); + identities.delete(profile); } const safeName = (value: string) => { diff --git a/agents/container/run.ts b/agents/container/run.ts index 7ae6fb8..0f3aa7b 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -78,7 +78,8 @@ type Inspect = { WorkingDir: string; Labels: Record | null }; HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; CapAdd: string[] | null; - NetworkMode: string; PidMode: string; IpcMode: string; PidsLimit: number; Memory: number; NanoCpus: number; + NetworkMode: string; PidMode: string; IpcMode: string; UTSMode: string; UsernsMode: string; CgroupnsMode: string; + PidsLimit: number; Memory: number; NanoCpus: number; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; @@ -111,6 +112,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0 || !exactNoNewPrivileges(host.SecurityOpt) || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' + || host.UTSMode !== '' || host.UsernsMode !== '' || host.CgroupnsMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) throw new Error('Container daemon configuration is missing required lockdown.'); @@ -137,6 +139,7 @@ export function validateContainer(container: string, profile: ContainerProfile, const requestedMounts = new Map((host.Mounts ?? []).map(item => [item.Target, item])); const requestedInput = requestedMounts.get('/run/codeboost-input'); if (requestedInput?.Type !== 'bind' || canonicalDockerBindSource(requestedInput.Source) !== profile.inputDirectory + || canonicalDockerBindSource(input.Source) !== profile.inputDirectory || !requestedInput.ReadOnly) throw new Error('Schema input mount identity changed.'); if (work.Name !== profile.filesystems.workVolume || metadata.Name !== profile.filesystems.metadataVolume) throw new Error('Container task volumes do not match their captured identity.'); @@ -177,7 +180,8 @@ export function validateContainer(container: string, profile: ContainerProfile, if (profile.vendor === 'codex' && (auth?.Type !== 'bind' || auth.RW)) throw new Error('Codex auth must be a read-only file mount.'); const requestedAuth = requestedMounts.get('/run/codeboost-auth/codex/auth.json'); if (profile.vendor === 'codex' && (requestedAuth?.Type !== 'bind' - || canonicalDockerBindSource(requestedAuth.Source) !== profile.codexAuthFile || !requestedAuth.ReadOnly)) + || canonicalDockerBindSource(requestedAuth.Source) !== profile.codexAuthFile + || canonicalDockerBindSource(auth!.Source) !== profile.codexAuthFile || !requestedAuth.ReadOnly)) throw new Error('Codex auth mount identity changed.'); if (profile.vendor === 'claude' && auth) throw new Error('Claude profile must not mount Codex auth.'); if (inspect.Config.Env.some(value => value.indexOf('=') < 1)) throw new Error('Container environment is malformed.'); diff --git a/agents/container/storage.ts b/agents/container/storage.ts index 7e5e8fe..ede1316 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -64,10 +64,13 @@ const remove = (args: readonly string[], inspectArgs: readonly string[], remaini env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); if (!absent(inspect)) throw new Error(`Failed to confirm removal of ${kind}.`); }; -const cleanup = (keeper: string, volumes: readonly string[], allocationId: string, timeoutMs = 30_000) => { +const cleanup = (containers: readonly string[], volumes: readonly string[], allocationId: string, timeoutMs = 30_000) => { const remaining = createDeadline(timeoutMs), failures: unknown[] = []; - try { remove(['rm', '--force', keeper], ['container', 'inspect', keeper], remaining, 'task keeper', allocationId); } - catch (error) { failures.push(error); } + for (const container of containers) { + try { remove(['rm', '--force', container], ['container', 'inspect', container], remaining, + 'task container', allocationId); } + catch (error) { failures.push(error); } + } for (const volume of volumes) { try { remove(['volume', 'rm', '--force', volume], ['volume', 'inspect', volume], remaining, 'task volume', allocationId); } catch (error) { failures.push(error); } @@ -103,14 +106,15 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); const allocationId = randomUUID(); const workVolume = `codeboost-work-${randomUUID()}`, metadataVolume = `codeboost-metadata-${randomUUID()}`; - const keeper = `codeboost-keeper-${randomUUID()}`, createdVolumes: string[] = []; + const keeper = `codeboost-keeper-${randomUUID()}`, seeder = `codeboost-seeder-${randomUUID()}`; + const createdVolumes: string[] = []; try { for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes], ['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) { + createdVolumes.push(name); docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, '--label', `io.codeboost.task-storage=${kind}`, '--label', `io.codeboost.allocation=${allocationId}`, name], remaining()); - createdVolumes.push(name); } const seed = ['set -eu', 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/', 'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/', 'rm -rf /work/.git', 'mkdir /work/.git', @@ -120,7 +124,8 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, '--label', 'io.codeboost.task-storage=keeper', '--label', `io.codeboost.allocation=${allocationId}`, '--entrypoint', 'sleep', imageId, 'infinity'], remaining()); - docker(['run', '--rm', '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN', + docker(['run', '--rm', '--name', seeder, '--label', `io.codeboost.allocation=${allocationId}`, + '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN', '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, @@ -131,7 +136,7 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi clone: Object.freeze({ ...clone, directory: staging }), limits: Object.freeze({ ...limits }) })); return filesystems; } catch (error) { - try { cleanup(keeper, createdVolumes.reverse(), allocationId); } + try { cleanup([seeder, keeper], createdVolumes.reverse(), allocationId); } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Task allocation failed and cleanup did not settle.'); } throw error; } @@ -140,6 +145,6 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi export function removeTaskFilesystems(filesystems: TaskFilesystems): void { assertTaskFilesystems(filesystems); const allocationId = taskFilesystemAllocationId(filesystems); - cleanup(filesystems.keeper, [filesystems.metadataVolume, filesystems.workVolume], allocationId); + cleanup([filesystems.keeper], [filesystems.metadataVolume, filesystems.workVolume], allocationId); allocations.delete(filesystems); } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index a006bd5..0dfe403 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -264,6 +264,12 @@ describe('real Docker agent isolation', () => { const state = JSON.parse(docker('container', 'inspect', valid.name))[0] as { State: { Status: string } }; expect(state.State.Status).toBe('created'); docker('rm', '--force', valid.name); containers.delete(valid.name); + + const imageIndex = valid.args.indexOf(imageId); + const namespaceArgs = [...valid.args.slice(0, imageIndex), '--uts=host', ...valid.args.slice(imageIndex)]; + docker(...namespaceArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); it('creates containers from the captured immutable image rather than its mutable tag', () => { From aa1bdbc58fee830c29a1690a817b1e570da886c2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:46:19 -0700 Subject: [PATCH 10/44] Allow clone ownership regression to settle --- test/agent-container.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 0dfe403..e930ad8 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -201,7 +201,7 @@ describe('real Docker agent isolation', () => { chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => createValidatedContainer(valid)).toThrow('only one bounded'); chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); - }); + }, 60_000); it('rejects extra security policies and environment paths that can escape bounded storage', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); From c4384627934e0a39bee237ef123e5047d825a185 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:51:42 -0700 Subject: [PATCH 11/44] Pin the D2 Codex state path --- agents/container/run.ts | 4 +++- test/agent-container.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/agents/container/run.ts b/agents/container/run.ts index 0f3aa7b..365b00f 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -203,7 +203,9 @@ export function validateContainer(container: string, profile: ContainerProfile, || environment.get('npm_config_cache') !== '/tmp/npm-cache' || environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache') throw new Error('Container isolation environment changed.'); - if (profile.vendor === 'codex' && names.includes('CLAUDE_CODE_OAUTH_TOKEN')) throw new Error('Credential profiles must not be combined.'); + if (profile.vendor === 'codex' && (names.includes('CLAUDE_CODE_OAUTH_TOKEN') + || environment.get('CODEX_HOME') !== '/run/codeboost-auth/codex')) + throw new Error('Credential profiles must not be combined or redirected.'); if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) throw new Error('Credential profiles must not be combined.'); assertContainerProfile(profile); diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index e930ad8..2ad4a5c 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -217,10 +217,10 @@ describe('real Docker agent isolation', () => { expect(() => validateContainer(valid.name, valid)).toThrow(/environment|PATH/); docker('rm', '--force', valid.name); containers.delete(valid.name); - for (const changedCache of ['npm_config_cache=/work/npm-cache', 'XDG_CACHE_HOME=/work/xdg-cache']) { - const cacheArgs = [...valid.args.slice(0, imageIndex), '--env', changedCache, ...valid.args.slice(imageIndex)]; - docker(...cacheArgs); containers.add(valid.name); - expect(() => validateContainer(valid.name, valid)).toThrow('isolation environment'); + for (const changedPath of ['npm_config_cache=/work/npm-cache', 'XDG_CACHE_HOME=/work/xdg-cache', 'CODEX_HOME=/work']) { + const changedArgs = [...valid.args.slice(0, imageIndex), '--env', changedPath, ...valid.args.slice(imageIndex)]; + docker(...changedArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow(/isolation environment|Credential profiles/); docker('rm', '--force', valid.name); containers.delete(valid.name); } }, 60_000); From a4bd301442fa7574f015e7514fd143b0b016b049 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 10:10:48 -0700 Subject: [PATCH 12/44] Seal D2 inputs and complete resource checks --- agents/container/profile.ts | 125 ++++++++++++++++++++++------------- agents/container/run.ts | 18 ++++- agents/container/storage.ts | 2 + git/clone.ts | 10 ++- test/agent-container.test.ts | 20 ++++-- 5 files changed, 122 insertions(+), 53 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index a48da4b..047c926 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; -import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, +import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -39,18 +39,40 @@ interface FileIdentity { readonly digest: string; } interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity; - readonly cleanupDirectory?: string; readonly filesystems: TaskFilesystems; readonly clone: InvocationInput['clone'] } + readonly cleanupDirectories: readonly string[]; readonly filesystems: TaskFilesystems; + readonly clone: InvocationInput['clone'] } type InputIdentity = Pick; +interface InputCapture extends InputIdentity { readonly content: Buffer } const identities = new WeakMap(); +const removeOwnedDirectory = (directory: string) => { + if (!lstatSync(directory, { throwIfNoEntry: false })) return; + chmodSync(directory, 0o700); + rmSync(directory, { recursive: true, force: true }); +}; +const removeOwnedDirectories = (directories: readonly string[]) => { + const failures: unknown[] = []; + for (const directory of directories) { + try { removeOwnedDirectory(directory); } catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, 'Profile snapshot cleanup did not settle.'); +}; const readCapturedFile = (path: string, kind: string): { identity: FileIdentity; content: Buffer } => { let fd: number | undefined; try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); const before = fstatSync(fd); - if (!before.isFile() || before.nlink !== 1 || before.size > 1024 * 1024) + const maximum = 1024 * 1024; + if (!before.isFile() || before.nlink !== 1 || before.size > maximum) throw new Error(`${kind} must be a bounded, unlinked regular file.`); - const content = readFileSync(fd); + const bounded = Buffer.allocUnsafe(maximum + 1); + let length = 0, count = 0; + do { + count = readSync(fd, bounded, length, bounded.length - length, null); + length += count; + } while (count > 0 && length < bounded.length); + if (length > maximum) throw new Error(`${kind} exceeds its maximum size.`); + const content = bounded.subarray(0, length); const after = fstatSync(fd); if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) @@ -65,7 +87,7 @@ const sameFile = (actual: FileIdentity, expected: FileIdentity) => actual.path = && actual.dev === expected.dev && actual.ino === expected.ino && actual.mode === expected.mode && actual.nlink === expected.nlink && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs && actual.digest === expected.digest; -const captureInput = (directory: string): InputIdentity => { +const captureInput = (directory: string): InputCapture => { const stat = lstatSync(directory); if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be a container-readable real directory.'); @@ -73,9 +95,9 @@ const captureInput = (directory: string): InputIdentity => { const entries = readdirSync(canonical); if (entries.length !== 1 || entries[0] !== 'schema.json') throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.'); - const schema = captureFile(`${canonical}/schema.json`, 'Schema input'); + const captured = readCapturedFile(`${canonical}/schema.json`, 'Schema input'), schema = captured.identity; if ((schema.mode & 0o004) === 0) throw new Error('Schema input must be container-readable.'); - return Object.freeze({ inputDirectory: canonical, schema }); + return Object.freeze({ inputDirectory: canonical, schema, content: captured.content }); }; /** Internal authenticity and host-file revalidation used at every launch boundary. */ @@ -96,7 +118,7 @@ export function assertContainerProfile(profile: ContainerProfile): void { export function disposeContainerProfile(profile: ContainerProfile): void { const identity = identities.get(profile); if (!identity) return; - if (identity.cleanupDirectory) rmSync(identity.cleanupDirectory, { recursive: true, force: true }); + removeOwnedDirectories(identity.cleanupDirectories); identities.delete(profile); } @@ -119,8 +141,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container profile requires the immutable built image ID.'); assertBuiltAgentImage(options.imageId); assertTaskFilesystems(filesystems, invocation.clone); - const inputIdentity = captureInput(options.inputDirectory); - const inputDirectory = inputIdentity.inputDirectory; + const sourceInput = captureInput(options.inputDirectory); if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile)) @@ -132,47 +153,59 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) throw new Error('Codex auth must be a direct regular file, not a link.'); const sourceAuth = options.codexAuthFile ? readCapturedFile(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; - let cleanupDirectory: string | undefined, codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined; - if (sourceAuth) { - cleanupDirectory = mkdtempSync(join(tmpdir(), 'codeboost-auth-')); - try { + const cleanupDirectories: string[] = []; + let codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined; + try { + const inputDirectory = mkdtempSync(join(tmpdir(), 'codeboost-input-')); + cleanupDirectories.push(inputDirectory); + writeFileSync(join(inputDirectory, 'schema.json'), sourceInput.content, + { mode: 0o400, flag: 'wx' }); + chmodSync(join(inputDirectory, 'schema.json'), 0o444); + chmodSync(inputDirectory, 0o555); + const inputIdentity = captureInput(inputDirectory); + if (sourceAuth) { + const cleanupDirectory = mkdtempSync(join(tmpdir(), 'codeboost-auth-')); + cleanupDirectories.push(cleanupDirectory); const stagedAuth = join(cleanupDirectory, 'auth.json'); writeFileSync(stagedAuth, sourceAuth.content, { mode: 0o400, flag: 'wx' }); chmodSync(stagedAuth, 0o444); codexAuthFile = mountSource(realpathSync(stagedAuth), 'Codex auth'); authIdentity = captureFile(codexAuthFile, 'Staged Codex auth'); - } catch (error) { - rmSync(cleanupDirectory, { recursive: true, force: true }); - throw error; } + const name = `codeboost-agent-${safeName(invocation.attemptId)}`, ownershipId = randomUUID(); + const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); + const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', + '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', + '--cpus=1', '--shm-size=16m', + '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + '--label', `io.codeboost.invocation=${ownershipId}`, + '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', + '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, + '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, + '--env', 'XDG_CACHE_HOME=/tmp/xdg-cache', + '--tmpfs', '/tmp:rw,nosuid,nodev,size=33554432,nr_inodes=4096,mode=1777', + '--tmpfs', '/home/codeboost:rw,nosuid,nodev,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', + '--mount', mount({ type: 'volume', source: filesystems.workVolume, target: '/work', readonly: readOnlyWork }), + '--mount', mount({ type: 'volume', source: filesystems.metadataVolume, target: '/work/.git', readonly: true }), + '--mount', mount({ type: 'bind', source: inputIdentity.inputDirectory, target: '/run/codeboost-input', readonly: true })]; + if (invocation.vendor === 'codex') { + args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', + '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', + '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); + } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); + args.push(options.imageId, ...options.command); + const capturedFilesystems = filesystems; + const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, + phase: invocation.phase, vendor: invocation.vendor, + filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile, + command: Object.freeze([...options.command]), ownershipId }); + identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema, + auth: authIdentity, + cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone })); + return profile; + } catch (error) { + try { removeOwnedDirectories(cleanupDirectories); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Profile creation and cleanup both failed.'); } + throw error; } - const name = `codeboost-agent-${safeName(invocation.attemptId)}`, ownershipId = randomUUID(); - const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); - const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', - '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1', - '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, - '--label', `io.codeboost.invocation=${ownershipId}`, - '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', - '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, - '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, - '--env', 'XDG_CACHE_HOME=/tmp/xdg-cache', - '--tmpfs', '/tmp:rw,nosuid,nodev,size=33554432,nr_inodes=4096,mode=1777', - '--tmpfs', '/home/codeboost:rw,nosuid,nodev,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', - '--mount', mount({ type: 'volume', source: filesystems.workVolume, target: '/work', readonly: readOnlyWork }), - '--mount', mount({ type: 'volume', source: filesystems.metadataVolume, target: '/work/.git', readonly: true }), - '--mount', mount({ type: 'bind', source: inputDirectory, target: '/run/codeboost-input', readonly: true })]; - if (invocation.vendor === 'codex') { - args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', - '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', - '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); - } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); - args.push(options.imageId, ...options.command); - const capturedFilesystems = filesystems; - const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, - phase: invocation.phase, vendor: invocation.vendor, - filesystems: capturedFilesystems, inputDirectory, codexAuthFile, - command: Object.freeze([...options.command]), ownershipId }); - identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity, cleanupDirectory, - filesystems, clone: invocation.clone })); - return profile; } diff --git a/agents/container/run.ts b/agents/container/run.ts index 365b00f..c837856 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -79,7 +79,13 @@ type Inspect = { HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; CapAdd: string[] | null; NetworkMode: string; PidMode: string; IpcMode: string; UTSMode: string; UsernsMode: string; CgroupnsMode: string; - PidsLimit: number; Memory: number; NanoCpus: number; + PidsLimit: number; Memory: number; MemorySwap: number; MemoryReservation: number; MemorySwappiness: number | null; + OomKillDisable: boolean; OomScoreAdj: number; NanoCpus: number; CpuShares: number; CpuPeriod: number; CpuQuota: number; + CpuRealtimePeriod: number; CpuRealtimeRuntime: number; CpusetCpus: string; CpusetMems: string; ShmSize: number; + BlkioWeight: number; BlkioWeightDevice: unknown[]; BlkioDeviceReadBps: unknown[]; BlkioDeviceWriteBps: unknown[]; + BlkioDeviceReadIOps: unknown[]; BlkioDeviceWriteIOps: unknown[]; Ulimits: unknown[]; CpuCount: number; + CpuPercent: number; IOMaximumBandwidth: number; IOMaximumIOps: number; DeviceCgroupRules: unknown[] | null; + StorageOpt?: Record | null; CgroupParent: string; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; @@ -114,7 +120,15 @@ export function validateContainer(container: string, profile: ContainerProfile, || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' || host.UTSMode !== '' || host.UsernsMode !== '' || host.CgroupnsMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 - || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) + || host.Memory !== 512 * 1024 * 1024 || host.MemorySwap !== 512 * 1024 * 1024 + || host.MemoryReservation !== 0 || host.MemorySwappiness !== null || host.OomKillDisable || host.OomScoreAdj !== 0 + || host.NanoCpus !== 1_000_000_000 || host.CpuShares !== 0 || host.CpuPeriod !== 0 || host.CpuQuota !== 0 + || host.CpuRealtimePeriod !== 0 || host.CpuRealtimeRuntime !== 0 || host.CpusetCpus !== '' || host.CpusetMems !== '' + || host.ShmSize !== 16 * 1024 * 1024 || host.BlkioWeight !== 0 + || host.BlkioWeightDevice.length || host.BlkioDeviceReadBps.length || host.BlkioDeviceWriteBps.length + || host.BlkioDeviceReadIOps.length || host.BlkioDeviceWriteIOps.length || host.Ulimits.length + || host.CpuCount !== 0 || host.CpuPercent !== 0 || host.IOMaximumBandwidth !== 0 || host.IOMaximumIOps !== 0 + || host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== '') throw new Error('Container daemon configuration is missing required lockdown.'); const tmpfs = host.Tmpfs ?? {}; const expectedTmpfs = new Map([ diff --git a/agents/container/storage.ts b/agents/container/storage.ts index ede1316..6990a59 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; import type { TaskClone } from '../contract.ts'; +import { assertTaskClone } from '../../git/clone.ts'; import { assertBuiltAgentImage } from './image.ts'; export interface TaskFilesystems { @@ -101,6 +102,7 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi for (const [name, value] of Object.entries(limits)) validLimit(value, name); if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); assertBuiltAgentImage(imageId); + assertTaskClone(clone); const remaining = createDeadline(timeoutMs), staging = realpathSync(clone.directory); if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); diff --git a/git/clone.ts b/git/clone.ts index cc9a0ca..879080f 100644 --- a/git/clone.ts +++ b/git/clone.ts @@ -4,6 +4,12 @@ import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node: import { isAbsolute, join, relative, resolve } from 'node:path'; import type { TaskClone } from '../agents/contract.ts'; +const trustedClones = new WeakSet(); + +export function assertTaskClone(clone: TaskClone): void { + if (!trustedClones.has(clone)) throw new Error('Task clone was not created by the trusted clone builder.'); +} + /** * Prepare an independent committed snapshot. This is trusted staging, not the * writable execution filesystem: D2 must reserve bounded storage and separate @@ -79,7 +85,9 @@ export function createTaskClone(options: { run(directory, 'remote', 'remove', 'origin'); run(directory, 'checkout', '--detach', options.head); if (run(directory, 'rev-parse', 'HEAD') !== options.head) throw new Error('Task head changed during clone.'); - return Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head }); + const clone = Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head }); + trustedClones.add(clone); + return clone; } catch (error) { rmSync(directory, { recursive: true, force: true }); throw error; diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 2ad4a5c..6264eee 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -176,8 +176,9 @@ describe('real Docker agent isolation', () => { } finally { spawnSync('docker', ['volume', 'rm', '--force', rogue], { stdio: 'ignore' }); } }, 60_000); - it('rejects cloned profiles and host inputs changed after capture', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + it('rejects cloned profiles while sealed snapshots ignore later host changes', () => { + const data = fixture(), valid = profile(data, 'planning', ['sh', '-c', + 'set -eu; grep -q codeboost-schema-marker /run/codeboost-input/schema.json; test ! -e /run/codeboost-input/extra.json']); const forged = Object.freeze({ ...valid, inputDirectory: '/', args: Object.freeze(valid.args.map(value => value.includes(`source=${data.input},`) ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); @@ -198,8 +199,11 @@ describe('real Docker agent isolation', () => { expect(statSync(valid.codexAuthFile!).mode & 0o777).toBe(0o444); writeFileSync(data.fakeAuth, '{}'); - chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); - expect(() => createValidatedContainer(valid)).toThrow('only one bounded'); + chmodSync(data.input, 0o755); chmodSync(join(data.input, 'schema.json'), 0o644); + writeFileSync(join(data.input, 'schema.json'), '{"probe":"changed"}\n'); + writeFileSync(join(data.input, 'extra.json'), '{}'); + chmodSync(join(data.input, 'schema.json'), 0o444); chmodSync(data.input, 0o555); + expect(runContainer(valid)).toBe(''); chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); }, 60_000); @@ -270,6 +274,11 @@ describe('real Docker agent isolation', () => { docker(...namespaceArgs); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); docker('rm', '--force', valid.name); containers.delete(valid.name); + + const resourceArgs = [...valid.args.slice(0, imageIndex), '--memory-swap=-1', ...valid.args.slice(imageIndex)]; + docker(...resourceArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); it('creates containers from the captured immutable image rather than its mutable tag', () => { @@ -285,6 +294,9 @@ describe('real Docker agent isolation', () => { expect(() => prepareTaskFilesystems(data.clone, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, AGENT_IMAGE)).toThrow('immutable built image ID'); + expect(() => prepareTaskFilesystems({ ...data.clone }, { + workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, + }, imageId)).toThrow('trusted clone builder'); }); if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { From 353efb0f2f5f415ed9df754d6788189c65bd277e Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 10:34:52 -0700 Subject: [PATCH 13/44] Add vendor-only egress and phase policy --- .github/workflows/agent-isolation.yml | 2 +- agents/container/Dockerfile | 3 +- agents/container/image.ts | 8 +- agents/container/profile.ts | 22 ++++- agents/container/run.ts | 13 ++- agents/network/network.ts | 130 ++++++++++++++++++++++++++ agents/network/proxy.mjs | 44 +++++++++ agents/policy.ts | 61 ++++++++++++ test/agent-container.test.ts | 95 +++++++++++-------- test/agent-network.test.ts | 48 ++++++++++ test/agent-policy.test.ts | 50 ++++++++++ 11 files changed, 426 insertions(+), 50 deletions(-) create mode 100644 agents/network/network.ts create mode 100644 agents/network/proxy.mjs create mode 100644 agents/policy.ts create mode 100644 test/agent-network.test.ts create mode 100644 test/agent-policy.test.ts diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index b568c04..c818679 100644 --- a/.github/workflows/agent-isolation.yml +++ b/.github/workflows/agent-isolation.yml @@ -22,4 +22,4 @@ jobs: cache: npm - run: npm ci --ignore-scripts - run: npm run typecheck - - run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts + - run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts test/agent-network.test.ts test/agent-policy.test.ts diff --git a/agents/container/Dockerfile b/agents/container/Dockerfile index 6711760..b1d197a 100644 --- a/agents/container/Dockerfile +++ b/agents/container/Dockerfile @@ -11,7 +11,8 @@ RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \ && install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost \ && install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git -COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe +COPY --chmod=0555 container/probe.sh /usr/local/bin/codeboost-container-probe +COPY --chmod=0444 network/proxy.mjs /usr/local/lib/codeboost-egress-proxy.mjs LABEL org.opencontainers.image.base.name="docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1" \ io.codeboost.codex.version="0.153.4" \ diff --git a/agents/container/image.ts b/agents/container/image.ts index cdfc655..a990fb3 100644 --- a/agents/container/image.ts +++ b/agents/container/image.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; export const AGENT_IMAGE = 'codeboost-agent:node26-codex0.153.4-claude2.1.281'; @@ -7,7 +7,8 @@ export const BASE_IMAGE = 'docker.io/library/node:26.7.0-bookworm@sha256:e929171 export const CODEX_VERSION = '0.153.4'; export const CLAUDE_VERSION = '2.1.281'; -const context = dirname(fileURLToPath(import.meta.url)); +const containerDirectory = dirname(fileURLToPath(import.meta.url)); +const context = dirname(containerDirectory); const trustedImages = new Set(); export function assertBuiltAgentImage(imageId: string): void { @@ -22,7 +23,8 @@ export function buildAgentImage(timeoutMs = 10 * 60_000): string { if (value <= 0) throw new Error('Agent image build exceeded its overall deadline.'); return value; }; - execFileSync('docker', ['build', '--pull=false', '--tag', AGENT_IMAGE, context], { + execFileSync('docker', ['build', '--pull=false', '--file', join(containerDirectory, 'Dockerfile'), + '--tag', AGENT_IMAGE, context], { timeout: remaining(), killSignal: 'SIGKILL', stdio: ['ignore', 'inherit', 'inherit'], }); const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], { diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 047c926..3cb1129 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -6,6 +6,8 @@ import { join } from 'node:path'; import type { InvocationInput, Phase } from '../contract.ts'; import { assertBuiltAgentImage } from './image.ts'; import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts'; +import { assertVendorNetwork, type VendorNetwork } from '../network/network.ts'; +import { assertPhasePolicy, type PhasePolicy } from '../policy.ts'; export interface ContainerProfile { readonly name: string; readonly args: readonly string[]; @@ -17,6 +19,8 @@ export interface ContainerProfile { readonly codexAuthFile?: string; readonly command: readonly string[]; readonly ownershipId: string; + readonly network: VendorNetwork; + readonly policy: PhasePolicy; } export interface ProfileOptions { readonly invocation: InvocationInput; @@ -26,6 +30,8 @@ export interface ProfileOptions { readonly imageId: string; readonly codexAuthFile?: string; readonly claudeToken?: string; + readonly network: VendorNetwork; + readonly policy: PhasePolicy; } interface FileIdentity { @@ -40,7 +46,8 @@ interface FileIdentity { } interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity; readonly cleanupDirectories: readonly string[]; readonly filesystems: TaskFilesystems; - readonly clone: InvocationInput['clone'] } + readonly clone: InvocationInput['clone']; readonly network: VendorNetwork; readonly policy: PhasePolicy; + readonly invocation: InvocationInput } type InputIdentity = Pick; interface InputCapture extends InputIdentity { readonly content: Buffer } const identities = new WeakMap(); @@ -105,6 +112,8 @@ export function assertContainerProfile(profile: ContainerProfile): void { const expected = identities.get(profile); if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); assertTaskFilesystems(expected.filesystems, expected.clone); + assertVendorNetwork(expected.network, profile.vendor); + assertPhasePolicy(expected.policy, expected.invocation); const actual = captureInput(expected.inputDirectory); if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) throw new Error('Schema input changed after the profile was captured.'); @@ -141,6 +150,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container profile requires the immutable built image ID.'); assertBuiltAgentImage(options.imageId); assertTaskFilesystems(filesystems, invocation.clone); + assertVendorNetwork(options.network, invocation.vendor); + assertPhasePolicy(options.policy, invocation); const sourceInput = captureInput(options.inputDirectory); if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); @@ -177,9 +188,11 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', '--cpus=1', '--shm-size=16m', - '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + `--network=${options.network.name}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, '--label', `io.codeboost.invocation=${ownershipId}`, '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', + '--env', `HTTPS_PROXY=${options.network.proxyUrl}`, '--env', `HTTP_PROXY=${options.network.proxyUrl}`, + '--env', 'NO_PROXY=localhost,127.0.0.1', '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, '--env', 'XDG_CACHE_HOME=/tmp/xdg-cache', @@ -198,10 +211,11 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile, - command: Object.freeze([...options.command]), ownershipId }); + command: Object.freeze([...options.command]), ownershipId, network: options.network, policy: options.policy }); identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema, auth: authIdentity, - cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone })); + cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone, + network: options.network, policy: options.policy, invocation })); return profile; } catch (error) { try { removeOwnedDirectories(cleanupDirectories); } diff --git a/agents/container/run.ts b/agents/container/run.ts index c837856..45ad5fd 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -89,6 +89,7 @@ type Inspect = { Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; + NetworkSettings: { Networks: Record }; }; /** Validate daemon-resolved configuration before starting an agent. */ @@ -117,7 +118,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || !host.ReadonlyRootfs || host.Privileged || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0 || !exactNoNewPrivileges(host.SecurityOpt) - || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' + || host.NetworkMode !== profile.network.name || host.PidMode !== '' || host.IpcMode !== 'private' || host.UTSMode !== '' || host.UsernsMode !== '' || host.CgroupnsMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 || host.Memory !== 512 * 1024 * 1024 || host.MemorySwap !== 512 * 1024 * 1024 @@ -130,6 +131,8 @@ export function validateContainer(container: string, profile: ContainerProfile, || host.CpuCount !== 0 || host.CpuPercent !== 0 || host.IOMaximumBandwidth !== 0 || host.IOMaximumIOps !== 0 || host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== '') throw new Error('Container daemon configuration is missing required lockdown.'); + if (JSON.stringify(Object.keys(inspect.NetworkSettings.Networks)) !== JSON.stringify([profile.network.name])) + throw new Error('Container network attachment changed.'); const tmpfs = host.Tmpfs ?? {}; const expectedTmpfs = new Map([ ['/tmp', ['rw', 'nosuid', 'nodev', 'size=33554432', 'nr_inodes=4096', 'mode=1777']], @@ -204,7 +207,8 @@ export function validateContainer(container: string, profile: ContainerProfile, const imageEnvironment = new Map((image?.Config?.Env ?? []).map(value => [value.slice(0, value.indexOf('=')), value.slice(value.indexOf('=') + 1)])); const allowedEnvironment = new Set(['PATH', 'NODE_VERSION', 'YARN_VERSION', 'HOME', 'CODEBOOST_PHASE', 'CODEBOOST_VENDOR', 'CODEBOOST_WORK_BYTES', 'CODEBOOST_WORK_INODES', 'CODEBOOST_METADATA_BYTES', 'CODEBOOST_METADATA_INODES', - 'npm_config_cache', 'XDG_CACHE_HOME', ...(profile.vendor === 'codex' ? ['CODEX_HOME'] : ['CLAUDE_CODE_OAUTH_TOKEN'])]); + 'npm_config_cache', 'XDG_CACHE_HOME', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', + ...(profile.vendor === 'codex' ? ['CODEX_HOME'] : ['CLAUDE_CODE_OAUTH_TOKEN'])]); if (new Set(names).size !== names.length || names.some(name => !allowedEnvironment.has(name))) throw new Error('Container includes an unexpected environment variable.'); if (environment.get('PATH') !== imageEnvironment.get('PATH') @@ -215,7 +219,10 @@ export function validateContainer(container: string, profile: ContainerProfile, || environment.get('CODEBOOST_METADATA_BYTES') !== String(profile.filesystems.metadataBytes) || environment.get('CODEBOOST_METADATA_INODES') !== String(profile.filesystems.metadataInodes) || environment.get('npm_config_cache') !== '/tmp/npm-cache' - || environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache') + || environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache' + || environment.get('HTTPS_PROXY') !== profile.network.proxyUrl + || environment.get('HTTP_PROXY') !== profile.network.proxyUrl + || environment.get('NO_PROXY') !== 'localhost,127.0.0.1') throw new Error('Container isolation environment changed.'); if (profile.vendor === 'codex' && (names.includes('CLAUDE_CODE_OAUTH_TOKEN') || environment.get('CODEX_HOME') !== '/run/codeboost-auth/codex')) diff --git a/agents/network/network.ts b/agents/network/network.ts new file mode 100644 index 0000000..0e6fbe2 --- /dev/null +++ b/agents/network/network.ts @@ -0,0 +1,130 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import type { InvocationInput } from '../contract.ts'; +import { assertBuiltAgentImage } from '../container/image.ts'; + +export const VENDOR_HOSTS = Object.freeze({ + claude: Object.freeze(['api.anthropic.com']), + codex: Object.freeze(['api.openai.com', 'chatgpt.com']), +} satisfies Record); + +export interface VendorNetwork { + readonly name: string; + readonly proxyContainer: string; + readonly proxyUrl: string; + readonly vendor: InvocationInput['vendor']; +} +interface NetworkIdentity { readonly allocationId: string; readonly imageId: string } +const identities = new WeakMap(); +const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); +const deadline = (timeoutMs: number) => { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Network deadline must be a positive integer.'); + const end = performance.now() + timeoutMs; + return () => { + const value = Math.ceil(end - performance.now()); + if (value <= 0) throw new Error('Vendor network operation exceeded its overall deadline.'); + return value; + }; +}; +const docker = (args: readonly string[], timeout: number) => execFileSync('docker', [...args], { + encoding: 'utf8', timeout, killSignal: 'SIGKILL', env: environment(), stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); +const absent = (result: ReturnType) => result.status !== 0 && !result.error + && /No such (?:object|container|network)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); +const remove = (args: readonly string[], inspect: readonly string[], remaining: () => number, kind: string, + allocationId: string) => { + const before = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (before.status !== 0) { + if (absent(before)) return; + throw new Error(`Failed to establish ownership of ${kind}.`); + } + const inspected = JSON.parse(before.stdout || '[]')[0] as + { Labels?: Record; Config?: { Labels?: Record } } | undefined; + const labels = inspected?.Labels ?? inspected?.Config?.Labels; + if (labels?.['io.codeboost.egress'] !== allocationId) throw new Error(`Refused to remove unowned ${kind}.`); + const result = spawnSync('docker', [...args], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.status === 0) return; + const check = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`); +}; + +export function assertVendorNetwork(network: VendorNetwork, vendor?: InvocationInput['vendor']): void { + const identity = identities.get(network); + if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); + if (vendor && network.vendor !== vendor) throw new Error('Vendor network does not match the invocation vendor.'); + assertBuiltAgentImage(identity.imageId); +} + +export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: string, + timeoutMs = 60_000): VendorNetwork { + assertBuiltAgentImage(imageId); + const remaining = deadline(timeoutMs), allocationId = randomUUID(); + const name = `codeboost-egress-${vendor}-${randomUUID()}`; + const proxyContainer = `codeboost-proxy-${vendor}-${randomUUID()}`; + let networkPlanned = false, proxyPlanned = false; + try { + networkPlanned = true; + docker(['network', 'create', '--internal', '--driver', 'bridge', + '--label', `io.codeboost.egress=${allocationId}`, name], remaining()); + proxyPlanned = true; + docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', + '--cpus=.25', '--network', name, '--network-alias', 'codeboost-proxy', + '--label', `io.codeboost.egress=${allocationId}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`, + '--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs'], remaining()); + docker(['network', 'connect', 'bridge', proxyContainer], remaining()); + docker(['exec', proxyContainer, 'node', '-e', [ + "const net=require('node:net');let attempts=0;", + "const check=()=>{const socket=net.connect(3128,'127.0.0.1');", + "socket.once('connect',()=>{socket.destroy();process.exit(0)});", + "socket.once('error',()=>{socket.destroy();if(++attempts===50)process.exit(1);setTimeout(check,20)})};check();", + ].join('')], remaining()); + const inspect = JSON.parse(docker(['container', 'inspect', proxyContainer], remaining()))[0] as + { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record; Env?: string[] }; + HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; + SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number }; + NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + const inspectedNetwork = JSON.parse(docker(['network', 'inspect', name], remaining()))[0] as + { Internal?: boolean; Driver?: string; Labels?: Record } | undefined; + const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); + if (!inspect?.State?.Running || inspect.Config?.Image !== imageId || inspect.Config?.User !== '10001:10001' + || inspect.Config?.Labels?.['io.codeboost.egress'] !== allocationId || !inspect.HostConfig?.ReadonlyRootfs + || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1 + || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') + || inspect.HostConfig.Memory !== 64 * 1024 * 1024 + || inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000 + || JSON.stringify(networks) !== JSON.stringify(['bridge', name].sort()) + || inspect.Mounts?.length || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`) + || !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge' + || inspectedNetwork.Labels?.['io.codeboost.egress'] !== allocationId) + throw new Error('Vendor proxy does not match its pinned isolation profile.'); + remaining(); + const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor }); + identities.set(network, Object.freeze({ allocationId, imageId })); + return network; + } catch (error) { + const failures: unknown[] = []; + if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer], + deadline(30_000), 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + if (networkPlanned) try { remove(['network', 'rm', name], ['network', 'inspect', name], + deadline(30_000), 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + if (failures.length) throw new AggregateError([error, ...failures], 'Vendor network creation and cleanup failed.'); + throw error; + } +} + +export function removeVendorNetwork(network: VendorNetwork): void { + assertVendorNetwork(network); + const allocationId = identities.get(network)!.allocationId; + const remaining = deadline(30_000), failures: unknown[] = []; + try { remove(['rm', '--force', network.proxyContainer], ['container', 'inspect', network.proxyContainer], + remaining, 'vendor proxy', allocationId); } catch (error) { failures.push(error); } + try { remove(['network', 'rm', network.name], ['network', 'inspect', network.name], + remaining, 'vendor network', allocationId); } catch (error) { failures.push(error); } + if (failures.length) throw new AggregateError(failures, 'Vendor network cleanup did not settle.'); + identities.delete(network); +} diff --git a/agents/network/proxy.mjs b/agents/network/proxy.mjs new file mode 100644 index 0000000..00cba4f --- /dev/null +++ b/agents/network/proxy.mjs @@ -0,0 +1,44 @@ +import { createServer, connect } from 'node:net'; + +const allowed = new Set((process.env.CODEBOOST_ALLOWED_HOSTS ?? '').split(',').filter(Boolean)); +if (!allowed.size) throw new Error('CODEBOOST_ALLOWED_HOSTS is required.'); + +const refuse = (socket, status = '403 Forbidden') => { + socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); +}; + +createServer(client => { + client.setTimeout(300_000, () => client.destroy()); + let request = Buffer.alloc(0), settled = false; + const receive = chunk => { + if (settled) return; + request = Buffer.concat([request, chunk], request.length + chunk.length); + if (request.length > 8192) { + settled = true; + refuse(client, '431 Request Header Fields Too Large'); + return; + } + const boundary = request.indexOf('\r\n\r\n'); + if (boundary < 0) return; + settled = true; + const line = request.subarray(0, request.indexOf('\r\n')).toString('ascii'); + const match = /^CONNECT ([a-z0-9.-]+):443 HTTP\/1\.[01]$/.exec(line); + const host = match?.[1]; + if (!host || !allowed.has(host)) { + refuse(client); + return; + } + const upstream = connect({ host, port: 443 }); + upstream.setTimeout(300_000, () => upstream.destroy()); + upstream.once('connect', () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + const remainder = request.subarray(boundary + 4); + if (remainder.length) upstream.write(remainder); + client.pipe(upstream).pipe(client); + }); + upstream.once('error', () => refuse(client, '502 Bad Gateway')); + client.once('error', () => upstream.destroy()); + }; + client.on('data', receive); + client.once('error', () => undefined); +}).listen(3128, '0.0.0.0'); diff --git a/agents/policy.ts b/agents/policy.ts new file mode 100644 index 0000000..b60aa54 --- /dev/null +++ b/agents/policy.ts @@ -0,0 +1,61 @@ +import type { InvocationInput, Phase } from './contract.ts'; +import { permitsCommand } from './contract.ts'; + +export type AgentTool = 'read' | 'list' | 'search' | 'write' | 'edit' | 'runner-command'; +export interface PhasePolicy { + readonly phase: Phase; + readonly worktree: 'read-only' | 'read-write'; + readonly tools: readonly AgentTool[]; + readonly web: false; + readonly mcp: false; +} +interface PolicyIdentity { readonly invocation: InvocationInput } +const identities = new WeakMap(); + +export function createPhasePolicy(invocation: InvocationInput): PhasePolicy { + const writable = invocation.phase === 'execute' || invocation.phase === 'fix'; + const tools: AgentTool[] = ['read', 'list', 'search']; + if (invocation.phase === 'review' || writable) tools.push('runner-command'); + if (writable) tools.push('write', 'edit'); + const policy = Object.freeze({ phase: invocation.phase, worktree: writable ? 'read-write' : 'read-only', + tools: Object.freeze(tools), web: false as const, mcp: false as const }); + identities.set(policy, Object.freeze({ invocation })); + return policy; +} + +export function assertPhasePolicy(policy: PhasePolicy, invocation?: InvocationInput): InvocationInput { + const identity = identities.get(policy); + if (!identity) throw new Error('Phase policy was not created by the trusted policy builder.'); + if (invocation && identity.invocation !== invocation) throw new Error('Phase policy does not belong to this invocation.'); + return identity.invocation; +} + +export function assertAgentTool(policy: PhasePolicy, tool: AgentTool): void { + assertPhasePolicy(policy); + if (!policy.tools.includes(tool)) throw new Error(`${tool} is forbidden during ${policy.phase}.`); +} + +export function dispatchApprovedCommand(policy: PhasePolicy, argv: readonly string[], + execute: (argv: readonly string[]) => T): T { + const invocation = assertPhasePolicy(policy); + assertAgentTool(policy, 'runner-command'); + if (!permitsCommand(invocation, argv)) throw new Error('Command argv was not approved exactly for this invocation.'); + return execute(Object.freeze([...argv])); +} + +export function createClaudeCommand(policy: PhasePolicy, prompt: string): readonly string[] { + if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.'); + assertPhasePolicy(policy); + const writable = policy.worktree === 'read-write'; + const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep'; + return Object.freeze(['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config', + '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--no-chrome', '--permission-prompts', 'none', + '--permission-mode', writable ? 'acceptEdits' : 'plan', '--allowedTools', allowed, + '--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir', '/run/codeboost-input']); +} + +export function codexBaseArguments(policy: PhasePolicy): readonly string[] { + assertPhasePolicy(policy); + return Object.freeze(['codex', '--strict-config', '--config', 'web_search="disabled"', + '--config', 'mcp_servers={}', '--ask-for-approval', 'never']); +} diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 6264eee..ca1c5b0 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -10,12 +10,15 @@ import { createContainerProfile, disposeContainerProfile } from '../agents/conta import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, hasExactOptions, validateContainer } from '../agents/container/run.ts'; import { createTaskClone } from '../git/clone.ts'; +import { createVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../agents/network/network.ts'; +import { codexBaseArguments, createClaudeCommand, createPhasePolicy } from '../agents/policy.ts'; const roots: string[] = []; const taskFilesystems: ReturnType[] = []; const containers = new Set(); const profiles: ReturnType[] = []; let imageId = ''; +let vendorNetworks: Record<'claude' | 'codex', VendorNetwork>; const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const docker = (...args: string[]) => execFileSync('docker', args, { @@ -45,25 +48,31 @@ function invocation(clone: ReturnType, phase: Phase, ven context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', referencedCodeHash: 'code-1', stateVersion: 1 } }); } +const governed = (captured: InvocationInput) => ({ invocation: captured, policy: createPhasePolicy(captured) }); function profile(data: ReturnType, phase: Phase, command: string[], options: { vendor?: 'codex' | 'claude'; authProbe?: boolean; codexAuthFile?: string; claudeToken?: string; } = {}) { const vendor = options.vendor ?? 'codex'; - const base = createContainerProfile({ invocation: invocation(data.clone, phase, vendor), filesystems: data.filesystems, + const captured = invocation(data.clone, phase, vendor); + const base = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, inputDirectory: data.input, command, - imageId, + imageId, network: vendorNetworks[vendor], codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); profiles.push(base); return base; } -beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000); +beforeAll(() => { + imageId = buildAgentImage(); + vendorNetworks = { claude: createVendorNetwork('claude', imageId), codex: createVendorNetwork('codex', imageId) }; +}, 10 * 60_000); afterAll(() => { for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); for (const profile of profiles) disposeContainerProfile(profile); + for (const network of Object.values(vendorNetworks).reverse()) removeVendorNetwork(network); for (const root of roots.reverse()) { chmodSync(join(root, 'input'), 0o700); rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); @@ -71,6 +80,15 @@ afterAll(() => { }, 120_000); describe('real Docker agent isolation', () => { + it.each(['planning', 'questions', 'review', 'execute', 'fix'] as const)( + '%s applies its enforced worktree access profile', phase => { + const data = fixture(), writable = phase === 'execute' || phase === 'fix'; + const command = writable + ? ['sh', '-c', `set -eu; printf ${phase} > /work/${phase}.txt; test -f /work/${phase}.txt`] + : ['sh', '-c', `set -eu; ! touch /work/${phase}.txt 2>/dev/null; test ! -e /work/${phase}.txt`]; + expect(runContainer(profile(data, phase, command))).toBe(''); + }, 60_000); + it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { const data = fixture(); process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; @@ -139,21 +157,23 @@ describe('real Docker agent isolation', () => { it('rejects mixed credentials and unsupported command/profile inputs', () => { const data = fixture(); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'codex'), + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'codex')), filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - claudeToken: 'must-not-combine', imageId })).toThrow('only'); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId })).toThrow('OAuth'); - const claudeProfile = createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), + claudeToken: 'must-not-combine', imageId, network: vendorNetworks.codex })).toThrow('only'); + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'claude')), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, + network: vendorNetworks.claude })).toThrow('OAuth'); + const claudeProfile = createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'claude')), filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, - claudeToken: 'serialization-sentinel' }); + claudeToken: 'serialization-sentinel', network: vendorNetworks.claude }); expect(JSON.stringify(claudeProfile)).not.toContain('serialization-sentinel'); expect(() => createValidatedContainer(claudeProfile)).toThrow('OAuth environment credential'); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), - filesystems: data.filesystems, inputDirectory: data.input, command: [], imageId })).toThrow('argv'); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), + filesystems: data.filesystems, inputDirectory: data.input, command: [], imageId, + network: vendorNetworks.codex })).toThrow('argv'); + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - imageId: AGENT_IMAGE })).toThrow('immutable built image ID'); + imageId: AGENT_IMAGE, network: vendorNetworks.codex })).toThrow('immutable built image ID'); chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => profile(data, 'planning', ['true'])).toThrow('only one bounded'); }); @@ -184,14 +204,14 @@ describe('real Docker agent isolation', () => { ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), filesystems: { ...data.filesystems }, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - imageId })).toThrow('trusted allocator'); + imageId, network: vendorNetworks.codex })).toThrow('trusted allocator'); const other = fixture(); - expect(() => createContainerProfile({ invocation: invocation(other.clone, 'planning'), + expect(() => createContainerProfile({ ...governed(invocation(other.clone, 'planning')), filesystems: data.filesystems, inputDirectory: other.input, command: ['true'], codexAuthFile: other.fakeAuth, - imageId })).toThrow('do not belong to the invocation clone'); + imageId, network: vendorNetworks.codex })).toThrow('do not belong to the invocation clone'); writeFileSync(data.fakeAuth, '{"changed":true}'); expect(valid.codexAuthFile).not.toBe(data.fakeAuth); @@ -221,7 +241,8 @@ describe('real Docker agent isolation', () => { expect(() => validateContainer(valid.name, valid)).toThrow(/environment|PATH/); docker('rm', '--force', valid.name); containers.delete(valid.name); - for (const changedPath of ['npm_config_cache=/work/npm-cache', 'XDG_CACHE_HOME=/work/xdg-cache', 'CODEX_HOME=/work']) { + for (const changedPath of ['npm_config_cache=/work/npm-cache', 'XDG_CACHE_HOME=/work/xdg-cache', + 'CODEX_HOME=/work', 'HTTPS_PROXY=http://example.com:3128']) { const changedArgs = [...valid.args.slice(0, imageIndex), '--env', changedPath, ...valid.args.slice(imageIndex)]; docker(...changedArgs); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow(/isolation environment|Credential profiles/); @@ -231,10 +252,10 @@ describe('real Docker agent isolation', () => { it('does not remove an active container when a duplicate attempt name collides', () => { const data = fixture(), captured = invocation(data.clone, 'planning'); - const first = createContainerProfile({ invocation: captured, filesystems: data.filesystems, - inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId }); - const duplicate = createContainerProfile({ invocation: captured, filesystems: data.filesystems, - inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId }); + const first = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, + inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId, network: vendorNetworks.codex }); + const duplicate = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, + inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId, network: vendorNetworks.codex }); profiles.push(first, duplicate); docker(...first.args); containers.add(first.name); expect(() => createValidatedContainer(duplicate)).toThrow(); @@ -260,9 +281,9 @@ describe('real Docker agent isolation', () => { expect(hasExactOptions([...expected, 'nosuid'].join(','), expected)).toBe(false); }, 60_000); - it('rejects a caller-mutated network before the container can start', () => { + it('rejects an unauthorized network before the container can start', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); - const args = valid.args.map(value => value === '--network=none' ? '--network=bridge' : value); + const args = valid.args.map(value => value.startsWith('--network=') ? '--network=bridge' : value); docker(...args); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); const state = JSON.parse(docker('container', 'inspect', valid.name))[0] as { State: { Status: string } }; @@ -303,27 +324,25 @@ describe('real Docker agent isolation', () => { it('runs the authenticated Codex startup path with isolated writable state', () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); - const authProfile = profile(data, 'planning', ['sh', '-c', [ - "codex exec --sandbox read-only --skip-git-repo-check --output-last-message /tmp/codex-output.txt 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.' >/tmp/codex-events.jsonl", - 'grep -Fx codeboost-schema-marker /tmp/codex-output.txt', - ].join('; ')], { authProbe: true, codexAuthFile: authFile }); - const args = authProfile.args.map(value => value === '--network=none' ? '--network=bridge' : value); - docker(...args); containers.add(authProfile.name); + const policy = createPhasePolicy(invocation(data.clone, 'planning', 'codex')); + const command = [...codexBaseArguments(policy), 'exec', '--sandbox', 'read-only', '--skip-git-repo-check', + 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.']; + const authProfile = profile(data, 'planning', command, { authProbe: true, codexAuthFile: authFile }); + docker(...authProfile.args); containers.add(authProfile.name); const output = docker('start', '--attach', authProfile.name); docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); - expect(output).toBe('codeboost-schema-marker'); + expect(output).toContain('codeboost-schema-marker'); }, 6 * 60_000); it('runs the authenticated Claude startup path with only its OAuth token', () => { const data = fixture(), token = process.env.CLAUDE_CODE_OAUTH_TOKEN; if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); - const authProfile = profile(data, 'planning', ['claude', '-p', - 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.', - '--output-format', 'json', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', - '--allowedTools', 'Read', '--add-dir', '/run/codeboost-input', - '--disallowedTools', 'WebFetch,WebSearch'], { vendor: 'claude', authProbe: true, claudeToken: token }); - const args = authProfile.args.map(value => value === '--network=none' ? '--network=bridge' : value); - const result = execFileSync('docker', args, { encoding: 'utf8', timeout: 60_000, + const policy = createPhasePolicy(invocation(data.clone, 'planning', 'claude')); + const command = createClaudeCommand(policy, + 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, without quotes or Markdown formatting.'); + const authProfile = profile(data, 'planning', [...command], + { vendor: 'claude', authProbe: true, claudeToken: token }); + const result = execFileSync('docker', authProfile.args, { encoding: 'utf8', timeout: 60_000, env: { PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, CLAUDE_CODE_OAUTH_TOKEN: token } }); void result; containers.add(authProfile.name); const output = docker('start', '--attach', authProfile.name); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts new file mode 100644 index 0000000..ab2361b --- /dev/null +++ b/test/agent-network.test.ts @@ -0,0 +1,48 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildAgentImage } from '../agents/container/image.ts'; +import { createVendorNetwork, removeVendorNetwork, VENDOR_HOSTS, type VendorNetwork } from '../agents/network/network.ts'; + +let imageId = '', network: VendorNetwork; +const docker = (...args: string[]) => execFileSync('docker', args, { + encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); +const curl = (url: string, direct = false) => spawnSync('docker', ['run', '--rm', `--network=${network.name}`, + '--env', `HTTPS_PROXY=${network.proxyUrl}`, ...(direct ? ['--env', 'NO_PROXY=*'] : []), + '--entrypoint', 'curl', imageId, '--silent', '--show-error', '--output', '/dev/null', '--write-out', '%{http_code}', + '--max-time', '15', url], { encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] }); + +beforeAll(() => { + imageId = buildAgentImage(); + network = createVendorNetwork('claude', imageId); +}, 10 * 60_000); +afterAll(() => removeVendorNetwork(network), 60_000); + +describe('vendor-only egress', () => { + it('pins the host list with each vendor profile', () => { + expect(VENDOR_HOSTS).toEqual({ claude: ['api.anthropic.com'], codex: ['api.openai.com', 'chatgpt.com'] }); + expect(Object.isFrozen(VENDOR_HOSTS.claude)).toBe(true); + expect(Object.isFrozen(VENDOR_HOSTS.codex)).toBe(true); + }); + + it('reaches the vendor through the proxy while blocking other and direct hosts', () => { + const vendor = curl('https://api.anthropic.com/'); + expect(vendor.status).toBe(0); + expect(vendor.stdout).toMatch(/^\d{3}$/); + expect(vendor.stdout).not.toBe('000'); + + const other = curl('https://example.com/'); + expect(other.status).not.toBe(0); + expect(other.stdout).toBe('000'); + expect(other.stderr).toContain('response 403'); + + const direct = curl('https://example.com/', true); + expect(direct.status).not.toBe(0); + expect(direct.stdout).toBe('000'); + }, 60_000); + + it('rejects a copied network capability', () => { + expect(() => createVendorNetwork('claude', imageId, 0)).toThrow('positive integer'); + expect(() => removeVendorNetwork({ ...network })).toThrow('trusted network builder'); + }); +}); diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts new file mode 100644 index 0000000..32e94b3 --- /dev/null +++ b/test/agent-policy.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; +import { assertAgentTool, codexBaseArguments, createClaudeCommand, createPhasePolicy, + dispatchApprovedCommand } from '../agents/policy.ts'; + +const request = (phase: Phase): InvocationInput => captureInvocation({ + clone: { id: 'clone-1', taskId: 'task-1', directory: '/tmp/task', head: 'a'.repeat(40) }, + vendor: 'claude', phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['npm', 'test']], + deadline: 2000, attemptId: `attempt-${phase}`, + context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, +}, 1000); + +describe('agent phase policy', () => { + it.each(['planning', 'questions'] as const)('%s exposes only non-mutating built-in tools', phase => { + const policy = createPhasePolicy(request(phase)); + expect(policy).toMatchObject({ phase, worktree: 'read-only', tools: ['read', 'list', 'search'], web: false, mcp: false }); + for (const tool of ['write', 'edit', 'runner-command'] as const) + expect(() => assertAgentTool(policy, tool)).toThrow(`forbidden during ${phase}`); + }); + + it('review dispatches only one exact approved argv without granting a shell tool', () => { + const captured = request('review'), policy = createPhasePolicy(captured), execute = vi.fn(argv => argv.join(' ')); + expect(policy.tools).toEqual(['read', 'list', 'search', 'runner-command']); + expect(dispatchApprovedCommand(policy, ['npm', 'test'], execute)).toBe('npm test'); + expect(execute).toHaveBeenCalledWith(['npm', 'test']); + expect(() => dispatchApprovedCommand(policy, ['npm', 'test', '--changed'], execute)).toThrow('not approved exactly'); + expect(() => dispatchApprovedCommand(policy, ['sh', '-c', 'npm test'], execute)).toThrow('not approved exactly'); + expect(() => assertAgentTool({ ...policy }, 'read')).toThrow('trusted policy builder'); + }); + + it.each(['execute', 'fix'] as const)('%s permits edits and exact runner commands', phase => { + const policy = createPhasePolicy(request(phase)); + expect(policy.worktree).toBe('read-write'); + for (const tool of ['read', 'list', 'search', 'write', 'edit', 'runner-command'] as const) + expect(() => assertAgentTool(policy, tool)).not.toThrow(); + expect(dispatchApprovedCommand(policy, ['npm', 'test'], argv => argv)).toEqual(['npm', 'test']); + }); + + it('builds Claude and Codex controls with web, MCP and direct shell disabled', () => { + const readonly = createPhasePolicy(request('planning')); + const claude = createClaudeCommand(readonly, 'Inspect the schema.'); + expect(claude).toContain('--strict-mcp-config'); + expect(claude).toContain('{"mcpServers":{}}'); + expect(claude).toContain('Read,Glob,Grep'); + expect(claude).toContain('Bash,WebFetch,WebSearch,NotebookEdit'); + expect(claude).not.toContain('Edit'); + expect(codexBaseArguments(readonly)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"', + '--config', 'mcp_servers={}', '--ask-for-approval', 'never']); + }); +}); From 15790331b76310014d2dfcfc9bfbd93cf3598bf1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 11:06:39 -0700 Subject: [PATCH 14/44] Enforce invocation-scoped agent policy --- agents/container/profile.ts | 15 ++-- agents/network/network.ts | 77 +++++++++++------- agents/policy.ts | 56 ++++++++++++- test/agent-container.test.ts | 152 ++++++++++++++++------------------- test/agent-network.test.ts | 34 +++++++- test/agent-policy.test.ts | 12 ++- 6 files changed, 215 insertions(+), 131 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 3cb1129..771abcf 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -7,7 +7,7 @@ import type { InvocationInput, Phase } from '../contract.ts'; import { assertBuiltAgentImage } from './image.ts'; import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts'; import { assertVendorNetwork, type VendorNetwork } from '../network/network.ts'; -import { assertPhasePolicy, type PhasePolicy } from '../policy.ts'; +import { assertAgentCommand, assertPhasePolicy, type AgentCommand, type PhasePolicy } from '../policy.ts'; export interface ContainerProfile { readonly name: string; readonly args: readonly string[]; @@ -26,7 +26,7 @@ export interface ProfileOptions { readonly invocation: InvocationInput; readonly filesystems: TaskFilesystems; readonly inputDirectory: string; - readonly command: readonly string[]; + readonly command: AgentCommand; readonly imageId: string; readonly codexAuthFile?: string; readonly claudeToken?: string; @@ -112,7 +112,7 @@ export function assertContainerProfile(profile: ContainerProfile): void { const expected = identities.get(profile); if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); assertTaskFilesystems(expected.filesystems, expected.clone); - assertVendorNetwork(expected.network, profile.vendor); + assertVendorNetwork(expected.network, expected.invocation, profile.name); assertPhasePolicy(expected.policy, expected.invocation); const actual = captureInput(expected.inputDirectory); if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) @@ -144,14 +144,13 @@ const mountSource = (path: string, kind: string) => { export function createContainerProfile(options: ProfileOptions): ContainerProfile { const { invocation, filesystems } = options; - if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0'))) - throw new Error('Container command must be a complete literal argv array.'); if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) throw new Error('Container profile requires the immutable built image ID.'); assertBuiltAgentImage(options.imageId); assertTaskFilesystems(filesystems, invocation.clone); - assertVendorNetwork(options.network, invocation.vendor); + assertVendorNetwork(options.network, invocation); assertPhasePolicy(options.policy, invocation); + const command = assertAgentCommand(options.command, options.policy); const sourceInput = captureInput(options.inputDirectory); if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); @@ -206,12 +205,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); - args.push(options.imageId, ...options.command); + args.push(options.imageId, ...command); const capturedFilesystems = filesystems; const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile, - command: Object.freeze([...options.command]), ownershipId, network: options.network, policy: options.policy }); + command: Object.freeze([...command]), ownershipId, network: options.network, policy: options.policy }); identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema, auth: authIdentity, cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone, diff --git a/agents/network/network.ts b/agents/network/network.ts index 0e6fbe2..3ea6286 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -14,7 +14,8 @@ export interface VendorNetwork { readonly proxyUrl: string; readonly vendor: InvocationInput['vendor']; } -interface NetworkIdentity { readonly allocationId: string; readonly imageId: string } +interface NetworkIdentity { readonly allocationId: string; readonly imageId: string; readonly invocation: InvocationInput; + readonly subnet: string } const identities = new WeakMap(); const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); const deadline = (timeoutMs: number) => { @@ -30,7 +31,7 @@ const docker = (args: readonly string[], timeout: number) => execFileSync('docke encoding: 'utf8', timeout, killSignal: 'SIGKILL', env: environment(), stdio: ['ignore', 'pipe', 'pipe'], }).trim(); const absent = (result: ReturnType) => result.status !== 0 && !result.error - && /No such (?:object|container|network)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); + && /(?:No such (?:object|container|network)|network .* not found)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); const remove = (args: readonly string[], inspect: readonly string[], remaining: () => number, kind: string, allocationId: string) => { const before = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', @@ -51,23 +52,56 @@ const remove = (args: readonly string[], inspect: readonly string[], remaining: if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`); }; -export function assertVendorNetwork(network: VendorNetwork, vendor?: InvocationInput['vendor']): void { +export function assertVendorNetwork(network: VendorNetwork, invocation?: InvocationInput, agentName?: string): void { const identity = identities.get(network); if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); - if (vendor && network.vendor !== vendor) throw new Error('Vendor network does not match the invocation vendor.'); + if (invocation && (identity.invocation !== invocation || network.vendor !== invocation.vendor)) + throw new Error('Vendor network does not belong to this invocation.'); assertBuiltAgentImage(identity.imageId); + const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], 30_000))[0] as + { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record; Env?: string[]; + Entrypoint?: string[] | null; Cmd?: string[] | null }; + HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; + SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number }; + NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], 30_000))[0] as + { Internal?: boolean; Driver?: string; Labels?: Record; IPAM?: { Config?: Array<{ Subnet?: string }> }; + Containers?: Record } | undefined; + const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); + const endpoints = Object.values(inspectedNetwork?.Containers ?? {}).map(value => value.Name).sort(); + const allowedEndpoints = [network.proxyContainer, ...(agentName ? [agentName] : [])]; + if (!inspect?.State?.Running || inspect.Config?.Image !== identity.imageId || inspect.Config?.User !== '10001:10001' + || inspect.Config?.Labels?.['io.codeboost.egress'] !== identity.allocationId || !inspect.HostConfig?.ReadonlyRootfs + || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1 + || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') + || inspect.HostConfig.PidsLimit !== 64 || inspect.HostConfig.Memory !== 64 * 1024 * 1024 + || inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000 + || JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length + || inspect.Config?.Entrypoint?.[0] !== 'node' + || JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs']) + || inspect.Config.Env?.filter(value => value.startsWith('CODEBOOST_ALLOWED_HOSTS=')).length !== 1 + || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[network.vendor].join(',')}`) + || !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge' + || inspectedNetwork.Labels?.['io.codeboost.egress'] !== identity.allocationId + || inspectedNetwork.IPAM?.Config?.length !== 1 || inspectedNetwork.IPAM.Config[0]?.Subnet !== identity.subnet + || !endpoints.includes(network.proxyContainer) || endpoints.some(name => !name || !allowedEndpoints.includes(name))) + throw new Error('Vendor network or proxy changed after allocation.'); } -export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: string, +export function createVendorNetwork(invocation: InvocationInput, imageId: string, timeoutMs = 60_000): VendorNetwork { assertBuiltAgentImage(imageId); + const vendor = invocation.vendor; const remaining = deadline(timeoutMs), allocationId = randomUUID(); const name = `codeboost-egress-${vendor}-${randomUUID()}`; const proxyContainer = `codeboost-proxy-${vendor}-${randomUUID()}`; + const subnetSeed = randomUUID().replaceAll('-', ''); + const subnet = `10.254.${parseInt(subnetSeed.slice(0, 2), 16)}.${parseInt(subnetSeed.slice(2, 4), 16) & 0xf8}/29`; let networkPlanned = false, proxyPlanned = false; try { networkPlanned = true; - docker(['network', 'create', '--internal', '--driver', 'bridge', + docker(['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, '--label', `io.codeboost.egress=${allocationId}`, name], remaining()); proxyPlanned = true; docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', @@ -82,29 +116,10 @@ export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: "socket.once('connect',()=>{socket.destroy();process.exit(0)});", "socket.once('error',()=>{socket.destroy();if(++attempts===50)process.exit(1);setTimeout(check,20)})};check();", ].join('')], remaining()); - const inspect = JSON.parse(docker(['container', 'inspect', proxyContainer], remaining()))[0] as - { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record; Env?: string[] }; - HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; - SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number }; - NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; - const inspectedNetwork = JSON.parse(docker(['network', 'inspect', name], remaining()))[0] as - { Internal?: boolean; Driver?: string; Labels?: Record } | undefined; - const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); - if (!inspect?.State?.Running || inspect.Config?.Image !== imageId || inspect.Config?.User !== '10001:10001' - || inspect.Config?.Labels?.['io.codeboost.egress'] !== allocationId || !inspect.HostConfig?.ReadonlyRootfs - || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') - || (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1 - || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') - || inspect.HostConfig.Memory !== 64 * 1024 * 1024 - || inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000 - || JSON.stringify(networks) !== JSON.stringify(['bridge', name].sort()) - || inspect.Mounts?.length || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`) - || !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge' - || inspectedNetwork.Labels?.['io.codeboost.egress'] !== allocationId) - throw new Error('Vendor proxy does not match its pinned isolation profile.'); - remaining(); const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor }); - identities.set(network, Object.freeze({ allocationId, imageId })); + identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet })); + assertVendorNetwork(network, invocation); + remaining(); return network; } catch (error) { const failures: unknown[] = []; @@ -118,8 +133,10 @@ export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: } export function removeVendorNetwork(network: VendorNetwork): void { - assertVendorNetwork(network); - const allocationId = identities.get(network)!.allocationId; + const identity = identities.get(network); + if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); + assertBuiltAgentImage(identity.imageId); + const allocationId = identity.allocationId; const remaining = deadline(30_000), failures: unknown[] = []; try { remove(['rm', '--force', network.proxyContainer], ['container', 'inspect', network.proxyContainer], remaining, 'vendor proxy', allocationId); } catch (error) { failures.push(error); } diff --git a/agents/policy.ts b/agents/policy.ts index b60aa54..edd2442 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -9,8 +9,22 @@ export interface PhasePolicy { readonly web: false; readonly mcp: false; } +export interface AgentCommand { readonly argv: readonly string[] } interface PolicyIdentity { readonly invocation: InvocationInput } const identities = new WeakMap(); +const commands = new WeakMap(); + +const command = (policy: PhasePolicy, argv: readonly string[]): AgentCommand => { + assertPhasePolicy(policy); + const value = Object.freeze({ argv: Object.freeze([...argv]) }); + commands.set(value, policy); + return value; +}; + +export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy): readonly string[] { + if (commands.get(value) !== policy) throw new Error('Container command was not generated for this phase policy.'); + return value.argv; +} export function createPhasePolicy(invocation: InvocationInput): PhasePolicy { const writable = invocation.phase === 'execute' || invocation.phase === 'fix'; @@ -43,19 +57,53 @@ export function dispatchApprovedCommand(policy: PhasePolicy, argv: readonly s return execute(Object.freeze([...argv])); } -export function createClaudeCommand(policy: PhasePolicy, prompt: string): readonly string[] { +export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentCommand { if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.'); assertPhasePolicy(policy); const writable = policy.worktree === 'read-write'; const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep'; - return Object.freeze(['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config', + return command(policy, ['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--no-chrome', '--permission-prompts', 'none', - '--permission-mode', writable ? 'acceptEdits' : 'plan', '--allowedTools', allowed, + '--permission-mode', writable ? 'acceptEdits' : 'plan', '--tools', allowed, '--allowedTools', allowed, '--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir', '/run/codeboost-input']); } export function codexBaseArguments(policy: PhasePolicy): readonly string[] { assertPhasePolicy(policy); return Object.freeze(['codex', '--strict-config', '--config', 'web_search="disabled"', - '--config', 'mcp_servers={}', '--ask-for-approval', 'never']); + '--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']); +} + +export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCommand { + if (!prompt || prompt.includes('\0')) throw new Error('Codex prompt must be nonempty and contain no NUL.'); + const sandbox = policy.worktree === 'read-write' ? 'workspace-write' : 'read-only'; + return command(policy, [...codexBaseArguments(policy), 'exec', '--sandbox', sandbox, '--skip-git-repo-check', prompt]); +} + +export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | 'persist-write' + | 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker'; + +/** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ +export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { + assertPhasePolicy(policy); + const phase = policy.phase; + const scripts: Record, string> = { + 'phase-worktree': policy.worktree === 'read-write' + ? `set -eu; printf ${phase} > /work/${phase}.txt; test -f /work/${phase}.txt` + : `set -eu; ! touch /work/${phase}.txt 2>/dev/null; test ! -e /work/${phase}.txt`, + 'read-only-isolation': 'set -eu; test "$(id -u)" = 10001; test "$(git status --porcelain)" = ""; ' + + 'test -z "${HOST_SECRET_SENTINEL:-}"; ! touch /work/forbidden; ! touch /usr/bin/forbidden; ' + + 'touch /tmp/allowed "$HOME/allowed"; printf isolated', + 'persist-write': 'set -eu; printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first', + 'persist-read': 'set -eu; test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain', + capacity: 'set -eu; ! dd if=/dev/zero of=/work/overflow bs=1M count=32 2>/dev/null; rm -f /work/overflow; ' + + 'mkdir /work/many; i=0; while touch "/work/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt 2000; done; ' + + 'test "$i" -lt 2000; test "$(find /work/many -type f | wc -l)" -eq "$i"; rm -rf /work/many; printf bounded', + metadata: 'set -eu; ! touch /work/.git/forbidden 2>/dev/null; ! ln /work/.git/HEAD /work/metadata-link 2>/dev/null; ' + + '! mv /work/.git /work/replaced 2>/dev/null; git status --porcelain; printf metadata-safe', + 'must-not-run': 'touch /tmp/command-ran', + 'input-marker': 'set -eu; grep -q codeboost-schema-marker /run/codeboost-input/schema.json; ' + + 'test ! -e /run/codeboost-input/extra.json', + }; + return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index ca1c5b0..aa4e661 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; import { AGENT_IMAGE, assertBuiltAgentImage, buildAgentImage } from '../agents/container/image.ts'; import { createContainerProfile, disposeContainerProfile } from '../agents/container/profile.ts'; @@ -11,14 +11,15 @@ import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems hasExactOptions, validateContainer } from '../agents/container/run.ts'; import { createTaskClone } from '../git/clone.ts'; import { createVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../agents/network/network.ts'; -import { codexBaseArguments, createClaudeCommand, createPhasePolicy } from '../agents/policy.ts'; +import { createClaudeCommand, createCodexCommand, createIsolationProbeCommand, createPhasePolicy, + type AgentCommand, type IsolationProbe } from '../agents/policy.ts'; const roots: string[] = []; const taskFilesystems: ReturnType[] = []; const containers = new Set(); const profiles: ReturnType[] = []; let imageId = ''; -let vendorNetworks: Record<'claude' | 'codex', VendorNetwork>; +const vendorNetworks: VendorNetwork[] = []; const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const docker = (...args: string[]) => execFileSync('docker', args, { @@ -48,16 +49,23 @@ function invocation(clone: ReturnType, phase: Phase, ven context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', referencedCodeHash: 'code-1', stateVersion: 1 } }); } -const governed = (captured: InvocationInput) => ({ invocation: captured, policy: createPhasePolicy(captured) }); +const governed = (captured: InvocationInput, probe: IsolationProbe = 'noop') => { + const policy = createPhasePolicy(captured), network = createVendorNetwork(captured, imageId); + vendorNetworks.push(network); + return { invocation: captured, policy, network, command: createIsolationProbeCommand(policy, probe) }; +}; -function profile(data: ReturnType, phase: Phase, command: string[], options: { +function profile(data: ReturnType, phase: Phase, + command: IsolationProbe | ((policy: ReturnType) => AgentCommand), options: { vendor?: 'codex' | 'claude'; authProbe?: boolean; codexAuthFile?: string; claudeToken?: string; } = {}) { const vendor = options.vendor ?? 'codex'; const captured = invocation(data.clone, phase, vendor); - const base = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, - inputDirectory: data.input, command, - imageId, network: vendorNetworks[vendor], + const policy = createPhasePolicy(captured), network = createVendorNetwork(captured, imageId); + vendorNetworks.push(network); + const trustedCommand = typeof command === 'string' ? createIsolationProbeCommand(policy, command) : command(policy); + const base = createContainerProfile({ invocation: captured, policy, network, filesystems: data.filesystems, + inputDirectory: data.input, command: trustedCommand, imageId, codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); profiles.push(base); @@ -66,13 +74,15 @@ function profile(data: ReturnType, phase: Phase, command: string beforeAll(() => { imageId = buildAgentImage(); - vendorNetworks = { claude: createVendorNetwork('claude', imageId), codex: createVendorNetwork('codex', imageId) }; }, 10 * 60_000); +afterEach(() => { + for (const network of vendorNetworks.splice(0).reverse()) removeVendorNetwork(network); +}, 120_000); afterAll(() => { for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); for (const profile of profiles) disposeContainerProfile(profile); - for (const network of Object.values(vendorNetworks).reverse()) removeVendorNetwork(network); + for (const network of vendorNetworks.splice(0).reverse()) removeVendorNetwork(network); for (const root of roots.reverse()) { chmodSync(join(root, 'input'), 0o700); rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); @@ -82,70 +92,41 @@ afterAll(() => { describe('real Docker agent isolation', () => { it.each(['planning', 'questions', 'review', 'execute', 'fix'] as const)( '%s applies its enforced worktree access profile', phase => { - const data = fixture(), writable = phase === 'execute' || phase === 'fix'; - const command = writable - ? ['sh', '-c', `set -eu; printf ${phase} > /work/${phase}.txt; test -f /work/${phase}.txt`] - : ['sh', '-c', `set -eu; ! touch /work/${phase}.txt 2>/dev/null; test ! -e /work/${phase}.txt`]; - expect(runContainer(profile(data, phase, command))).toBe(''); + const data = fixture(); + expect(runContainer(profile(data, phase, 'phase-worktree'))).toBe(''); }, 60_000); it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { const data = fixture(); process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; try { - const output = runContainer(profile(data, 'planning', ['sh', '-c', ['set -eu', - 'test "$(id -u)" = 10001', - 'test "$(git status --porcelain)" = ""', - 'test ! -e "$1"', - 'test -z "${HOST_SECRET_SENTINEL:-}"', - '! touch /work/forbidden', - '! touch /usr/bin/forbidden', - 'touch /tmp/allowed "$HOME/allowed"', - 'printf isolated', - ].join('; '), 'probe', data.source])); + const output = runContainer(profile(data, 'planning', 'read-only-isolation')); expect(output).toBe('isolated'); } finally { delete process.env.HOST_SECRET_SENTINEL; } }, 60_000); it('persists execution changes while replacing HOME and scratch for each invocation', () => { const data = fixture(); - expect(runContainer(profile(data, 'execute', ['sh', '-c', - 'set -eu; printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first']))).toBe('first'); - const output = runContainer(profile(data, 'execute', ['sh', '-c', - 'set -eu; test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain'])); + expect(runContainer(profile(data, 'execute', 'persist-write'))).toBe('first'); + const output = runContainer(profile(data, 'execute', 'persist-read')); expect(output).toContain('?? generated.txt'); }, 60_000); it('enforces work byte and inode ceilings before writes can exceed the allocation', () => { const data = fixture(); - const output = runContainer(profile(data, 'execute', ['sh', '-c', ['set -eu', - '! dd if=/dev/zero of=/work/overflow bs=1M count=32 2>/dev/null', - 'rm -f /work/overflow', - 'mkdir /work/many', - 'i=0; while touch "/work/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt 2000; done', - 'test "$i" -lt 2000', - 'test "$(find /work/many -type f | wc -l)" -eq "$i"', - 'rm -rf /work/many', - 'printf bounded', - ].join('; ')])); + const output = runContainer(profile(data, 'execute', 'capacity')); expect(output).toBe('bounded'); }, 60_000); it('keeps Git metadata read-only, on another filesystem, and mounted against replacement', () => { const data = fixture(); - const output = runContainer(profile(data, 'execute', ['sh', '-c', ['set -eu', - '! touch /work/.git/forbidden 2>/dev/null', - '! ln /work/.git/HEAD /work/metadata-link 2>/dev/null', - '! mv /work/.git /work/replaced 2>/dev/null', - 'git status --porcelain', - 'printf metadata-safe', - ].join('; ')])); + const output = runContainer(profile(data, 'execute', 'metadata')); expect(output).toBe('metadata-safe'); }, 60_000); it('refuses a container missing read-only root before its command runs', () => { const data = fixture(); - const valid = profile(data, 'planning', ['sh', '-c', 'touch /tmp/command-ran']); + const valid = profile(data, 'planning', 'must-not-run'); const args = valid.args.filter(value => value !== '--read-only'); docker(...args); containers.add(valid.name); @@ -158,28 +139,26 @@ describe('real Docker agent isolation', () => { it('rejects mixed credentials and unsupported command/profile inputs', () => { const data = fixture(); expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'codex')), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - claudeToken: 'must-not-combine', imageId, network: vendorNetworks.codex })).toThrow('only'); + filesystems: data.filesystems, inputDirectory: data.input, codexAuthFile: data.fakeAuth, + claudeToken: 'must-not-combine', imageId })).toThrow('only'); expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'claude')), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, - network: vendorNetworks.claude })).toThrow('OAuth'); + filesystems: data.filesystems, inputDirectory: data.input, imageId })).toThrow('OAuth'); const claudeProfile = createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'claude')), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, - claudeToken: 'serialization-sentinel', network: vendorNetworks.claude }); + filesystems: data.filesystems, inputDirectory: data.input, imageId, claudeToken: 'serialization-sentinel' }); expect(JSON.stringify(claudeProfile)).not.toContain('serialization-sentinel'); expect(() => createValidatedContainer(claudeProfile)).toThrow('OAuth environment credential'); + const untrusted = governed(invocation(data.clone, 'planning')); + expect(() => createContainerProfile({ ...untrusted, command: { argv: ['true'] }, filesystems: data.filesystems, + inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId })).toThrow('not generated'); expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), - filesystems: data.filesystems, inputDirectory: data.input, command: [], imageId, - network: vendorNetworks.codex })).toThrow('argv'); - expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - imageId: AGENT_IMAGE, network: vendorNetworks.codex })).toThrow('immutable built image ID'); + filesystems: data.filesystems, inputDirectory: data.input, codexAuthFile: data.fakeAuth, + imageId: AGENT_IMAGE })).toThrow('immutable built image ID'); chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); - expect(() => profile(data, 'planning', ['true'])).toThrow('only one bounded'); + expect(() => profile(data, 'planning', 'noop')).toThrow('only one bounded'); }); it('rejects unexpected host mounts and unbounded task volumes after Docker resolves them', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); const imageIndex = valid.args.indexOf(imageId); const extraMountArgs = [...valid.args.slice(0, imageIndex), '--mount', 'type=bind,source=/tmp,target=/unexpected,readonly', ...valid.args.slice(imageIndex)]; @@ -197,21 +176,20 @@ describe('real Docker agent isolation', () => { }, 60_000); it('rejects cloned profiles while sealed snapshots ignore later host changes', () => { - const data = fixture(), valid = profile(data, 'planning', ['sh', '-c', - 'set -eu; grep -q codeboost-schema-marker /run/codeboost-input/schema.json; test ! -e /run/codeboost-input/extra.json']); + const data = fixture(), valid = profile(data, 'planning', 'input-marker'); const forged = Object.freeze({ ...valid, inputDirectory: '/', args: Object.freeze(valid.args.map(value => value.includes(`source=${data.input},`) ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), - filesystems: { ...data.filesystems }, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - imageId, network: vendorNetworks.codex })).toThrow('trusted allocator'); + filesystems: { ...data.filesystems }, inputDirectory: data.input, codexAuthFile: data.fakeAuth, + imageId })).toThrow('trusted allocator'); const other = fixture(); expect(() => createContainerProfile({ ...governed(invocation(other.clone, 'planning')), - filesystems: data.filesystems, inputDirectory: other.input, command: ['true'], codexAuthFile: other.fakeAuth, - imageId, network: vendorNetworks.codex })).toThrow('do not belong to the invocation clone'); + filesystems: data.filesystems, inputDirectory: other.input, codexAuthFile: other.fakeAuth, + imageId })).toThrow('do not belong to the invocation clone'); writeFileSync(data.fakeAuth, '{"changed":true}'); expect(valid.codexAuthFile).not.toBe(data.fakeAuth); @@ -228,7 +206,7 @@ describe('real Docker agent isolation', () => { }, 60_000); it('rejects extra security policies and environment paths that can escape bounded storage', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); const imageIndex = valid.args.indexOf(imageId); const securityArgs = [...valid.args.slice(0, imageIndex), '--security-opt', 'seccomp=unconfined', ...valid.args.slice(imageIndex)]; @@ -252,10 +230,11 @@ describe('real Docker agent isolation', () => { it('does not remove an active container when a duplicate attempt name collides', () => { const data = fixture(), captured = invocation(data.clone, 'planning'); - const first = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, - inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId, network: vendorNetworks.codex }); - const duplicate = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, - inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId, network: vendorNetworks.codex }); + const common = governed(captured); + const first = createContainerProfile({ ...common, filesystems: data.filesystems, + inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId }); + const duplicate = createContainerProfile({ ...common, filesystems: data.filesystems, + inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId }); profiles.push(first, duplicate); docker(...first.args); containers.add(first.name); expect(() => createValidatedContainer(duplicate)).toThrow(); @@ -265,7 +244,7 @@ describe('real Docker agent isolation', () => { }, 60_000); it('rejects added capabilities and conflicting or duplicate filesystem options', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); const imageIndex = valid.args.indexOf(imageId); const args = [...valid.args.slice(0, imageIndex), '--cap-add=SYS_ADMIN', ...valid.args.slice(imageIndex)]; docker(...args); containers.add(valid.name); @@ -282,7 +261,7 @@ describe('real Docker agent isolation', () => { }, 60_000); it('rejects an unauthorized network before the container can start', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); const args = valid.args.map(value => value.startsWith('--network=') ? '--network=bridge' : value); docker(...args); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); @@ -302,8 +281,20 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); + it('rejects a new endpoint attached to the invocation network before launch', () => { + const data = fixture(), valid = profile(data, 'planning', 'must-not-run'); + const rogue = `codeboost-rogue-${randomUUID()}`; + try { + docker('run', '--detach', '--name', rogue, `--network=${valid.network.name}`, '--entrypoint', 'node', imageId, + '-e', 'setInterval(()=>{},1000)'); + expect(() => createValidatedContainer(valid)).toThrow('network or proxy changed'); + const absent = spawnSync('docker', ['container', 'inspect', valid.name], { encoding: 'utf8' }); + expect(absent.status).not.toBe(0); + } finally { spawnSync('docker', ['rm', '--force', rogue], { stdio: 'ignore' }); } + }, 60_000); + it('creates containers from the captured immutable image rather than its mutable tag', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); expect(valid.expectedImage).toBe(imageId); expect(valid.args).toContain(imageId); expect(valid.args).not.toContain(AGENT_IMAGE); @@ -324,10 +315,9 @@ describe('real Docker agent isolation', () => { it('runs the authenticated Codex startup path with isolated writable state', () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); - const policy = createPhasePolicy(invocation(data.clone, 'planning', 'codex')); - const command = [...codexBaseArguments(policy), 'exec', '--sandbox', 'read-only', '--skip-git-repo-check', - 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.']; - const authProfile = profile(data, 'planning', command, { authProbe: true, codexAuthFile: authFile }); + const authProfile = profile(data, 'planning', policy => createCodexCommand(policy, + 'Reply only with this exact marker: codeboost-schema-marker'), + { authProbe: true, codexAuthFile: authFile }); docker(...authProfile.args); containers.add(authProfile.name); const output = docker('start', '--attach', authProfile.name); docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); @@ -337,10 +327,8 @@ describe('real Docker agent isolation', () => { it('runs the authenticated Claude startup path with only its OAuth token', () => { const data = fixture(), token = process.env.CLAUDE_CODE_OAUTH_TOKEN; if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); - const policy = createPhasePolicy(invocation(data.clone, 'planning', 'claude')); - const command = createClaudeCommand(policy, - 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, without quotes or Markdown formatting.'); - const authProfile = profile(data, 'planning', [...command], + const authProfile = profile(data, 'planning', policy => createClaudeCommand(policy, + 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, without quotes or Markdown formatting.'), { vendor: 'claude', authProbe: true, claudeToken: token }); const result = execFileSync('docker', authProfile.args, { encoding: 'utf8', timeout: 60_000, env: { PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, CLAUDE_CODE_OAUTH_TOKEN: token } }); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index ab2361b..003cb14 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -1,9 +1,17 @@ import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildAgentImage } from '../agents/container/image.ts'; -import { createVendorNetwork, removeVendorNetwork, VENDOR_HOSTS, type VendorNetwork } from '../agents/network/network.ts'; +import { assertVendorNetwork, createVendorNetwork, removeVendorNetwork, VENDOR_HOSTS, + type VendorNetwork } from '../agents/network/network.ts'; +import { captureInvocation } from '../agents/contract.ts'; let imageId = '', network: VendorNetwork; +const invocation = captureInvocation({ + clone: { id: 'clone-network', taskId: 'task-network', directory: '/tmp/network', head: 'a'.repeat(40) }, + vendor: 'claude', phase: 'planning', approvedArgv: [], deadline: Date.now() + 60_000, attemptId: 'network-probe', + context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, +}); const docker = (...args: string[]) => execFileSync('docker', args, { encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], }).trim(); @@ -14,7 +22,7 @@ const curl = (url: string, direct = false) => spawnSync('docker', ['run', '--rm' beforeAll(() => { imageId = buildAgentImage(); - network = createVendorNetwork('claude', imageId); + network = createVendorNetwork(invocation, imageId); }, 10 * 60_000); afterAll(() => removeVendorNetwork(network), 60_000); @@ -42,7 +50,27 @@ describe('vendor-only egress', () => { }, 60_000); it('rejects a copied network capability', () => { - expect(() => createVendorNetwork('claude', imageId, 0)).toThrow('positive integer'); + expect(() => createVendorNetwork(invocation, imageId, 0)).toThrow('positive integer'); expect(() => removeVendorNetwork({ ...network })).toThrow('trusted network builder'); + const otherInvocation = captureInvocation({ ...invocation, attemptId: 'other-network-probe', + deadline: Date.now() + 60_000 }); + expect(() => assertVendorNetwork(network, otherInvocation)).toThrow('does not belong'); }); + + it('keeps concurrent invocations on separate internal networks', () => { + const otherInvocation = captureInvocation({ ...invocation, attemptId: 'concurrent-network-probe', + deadline: Date.now() + 60_000 }); + const other = createVendorNetwork(otherInvocation, imageId), peer = `codeboost-peer-${randomUUID()}`; + try { + docker('run', '--detach', '--name', peer, `--network=${network.name}`, '--network-alias', 'codeboost-peer', + '--entrypoint', 'node', imageId, '-e', "require('node:net').createServer(()=>{}).listen(4567,'0.0.0.0');setInterval(()=>{},1000)"); + const result = spawnSync('docker', ['run', '--rm', `--network=${other.name}`, '--entrypoint', 'node', imageId, + '-e', "const s=require('node:net').connect(4567,'codeboost-peer');s.on('connect',()=>process.exit(0));s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(2),3000)"], + { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }); + expect(result.status).not.toBe(0); + } finally { + spawnSync('docker', ['rm', '--force', peer], { stdio: 'ignore' }); + removeVendorNetwork(other); + } + }, 60_000); }); diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts index 32e94b3..dfc6186 100644 --- a/test/agent-policy.test.ts +++ b/test/agent-policy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; -import { assertAgentTool, codexBaseArguments, createClaudeCommand, createPhasePolicy, - dispatchApprovedCommand } from '../agents/policy.ts'; +import { assertAgentCommand, assertAgentTool, codexBaseArguments, createClaudeCommand, createCodexCommand, + createPhasePolicy, dispatchApprovedCommand } from '../agents/policy.ts'; const request = (phase: Phase): InvocationInput => captureInvocation({ clone: { id: 'clone-1', taskId: 'task-1', directory: '/tmp/task', head: 'a'.repeat(40) }, @@ -38,13 +38,17 @@ describe('agent phase policy', () => { it('builds Claude and Codex controls with web, MCP and direct shell disabled', () => { const readonly = createPhasePolicy(request('planning')); - const claude = createClaudeCommand(readonly, 'Inspect the schema.'); + const claude = createClaudeCommand(readonly, 'Inspect the schema.').argv; expect(claude).toContain('--strict-mcp-config'); expect(claude).toContain('{"mcpServers":{}}'); + expect(claude).toContain('--tools'); expect(claude).toContain('Read,Glob,Grep'); expect(claude).toContain('Bash,WebFetch,WebSearch,NotebookEdit'); expect(claude).not.toContain('Edit'); expect(codexBaseArguments(readonly)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"', - '--config', 'mcp_servers={}', '--ask-for-approval', 'never']); + '--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']); + const codex = createCodexCommand(readonly, 'Inspect the schema.'); + expect(codex.argv).toContain('features.shell_tool=false'); + expect(() => assertAgentCommand({ argv: codex.argv }, readonly)).toThrow('not generated'); }); }); From 945e79619c552cce37e9cf513d90c50e60d3fc4a Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 11:21:41 -0700 Subject: [PATCH 15/44] Bind adapters and proxy checks to invocation --- agents/container/profile.ts | 6 +++--- agents/container/run.ts | 10 +++++----- agents/network/network.ts | 23 ++++++++++++++++++----- agents/policy.ts | 15 +++++++++------ test/agent-container.test.ts | 2 +- test/agent-network.test.ts | 20 ++++++++++++++++++++ test/agent-policy.test.ts | 14 +++++++++----- 7 files changed, 65 insertions(+), 25 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 771abcf..1c09d22 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -108,11 +108,11 @@ const captureInput = (directory: string): InputCapture => { }; /** Internal authenticity and host-file revalidation used at every launch boundary. */ -export function assertContainerProfile(profile: ContainerProfile): void { +export function assertContainerProfile(profile: ContainerProfile, timeoutMs = 30_000): void { const expected = identities.get(profile); if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); assertTaskFilesystems(expected.filesystems, expected.clone); - assertVendorNetwork(expected.network, expected.invocation, profile.name); + assertVendorNetwork(expected.network, expected.invocation, profile.name, timeoutMs); assertPhasePolicy(expected.policy, expected.invocation); const actual = captureInput(expected.inputDirectory); if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) @@ -150,7 +150,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil assertTaskFilesystems(filesystems, invocation.clone); assertVendorNetwork(options.network, invocation); assertPhasePolicy(options.policy, invocation); - const command = assertAgentCommand(options.command, options.policy); + const command = assertAgentCommand(options.command, options.policy, invocation.vendor); const sourceInput = captureInput(options.inputDirectory); if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); diff --git a/agents/container/run.ts b/agents/container/run.ts index 45ad5fd..1691b61 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -95,7 +95,7 @@ type Inspect = { /** Validate daemon-resolved configuration before starting an agent. */ export function validateContainer(container: string, profile: ContainerProfile, timeoutMs = 30_000): void { const remaining = createDeadline(timeoutMs); - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); const inspect = JSON.parse(docker(['container', 'inspect', container], { timeoutMs: remaining() }))[0] as Inspect | undefined; if (!inspect) throw new Error('Docker did not return the created container.'); const image = JSON.parse(docker(['image', 'inspect', profile.expectedImage], { timeoutMs: remaining() }))[0] as @@ -229,7 +229,7 @@ export function validateContainer(container: string, profile: ContainerProfile, throw new Error('Credential profiles must not be combined or redirected.'); if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) throw new Error('Credential profiles must not be combined.'); - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); remaining(); } @@ -238,10 +238,10 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs = const remaining = createDeadline(timeoutMs); try { validateSecrets(profile, secrets); - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); docker(profile.args, { timeoutMs: remaining(), secrets }); validateContainer(profile.name, profile, remaining()); - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); remaining(); return profile.name; } catch (error) { @@ -257,7 +257,7 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, const container = createValidatedContainer(profile, remaining(), secrets); let failure: unknown; try { - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); const output = docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); remaining(); return output; diff --git a/agents/network/network.ts b/agents/network/network.ts index 3ea6286..4a8c37e 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -52,19 +52,22 @@ const remove = (args: readonly string[], inspect: readonly string[], remaining: if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`); }; -export function assertVendorNetwork(network: VendorNetwork, invocation?: InvocationInput, agentName?: string): void { +const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInput | undefined, + agentName: string | undefined, remaining: () => number): void => { const identity = identities.get(network); if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); if (invocation && (identity.invocation !== invocation || network.vendor !== invocation.vendor)) throw new Error('Vendor network does not belong to this invocation.'); assertBuiltAgentImage(identity.imageId); - const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], 30_000))[0] as + const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], remaining()))[0] as { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record; Env?: string[]; Entrypoint?: string[] | null; Cmd?: string[] | null }; HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; - SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number }; + SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number; + NetworkMode?: string; PidMode?: string; IpcMode?: string; UTSMode?: string; UsernsMode?: string; + CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null }; NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; - const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], 30_000))[0] as + const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], remaining()))[0] as { Internal?: boolean; Driver?: string; Labels?: Record; IPAM?: { Config?: Array<{ Subnet?: string }> }; Containers?: Record } | undefined; const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); @@ -77,6 +80,10 @@ export function assertVendorNetwork(network: VendorNetwork, invocation?: Invocat || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') || inspect.HostConfig.PidsLimit !== 64 || inspect.HostConfig.Memory !== 64 * 1024 * 1024 || inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000 + || inspect.HostConfig.NetworkMode !== network.name || inspect.HostConfig.PidMode !== '' + || inspect.HostConfig.IpcMode !== 'private' || inspect.HostConfig.UTSMode !== '' + || inspect.HostConfig.UsernsMode !== '' || inspect.HostConfig.CgroupnsMode !== 'private' + || (inspect.HostConfig.Devices?.length ?? 0) !== 0 || (inspect.HostConfig.DeviceRequests?.length ?? 0) !== 0 || JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length || inspect.Config?.Entrypoint?.[0] !== 'node' || JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs']) @@ -87,6 +94,12 @@ export function assertVendorNetwork(network: VendorNetwork, invocation?: Invocat || inspectedNetwork.IPAM?.Config?.length !== 1 || inspectedNetwork.IPAM.Config[0]?.Subnet !== identity.subnet || !endpoints.includes(network.proxyContainer) || endpoints.some(name => !name || !allowedEndpoints.includes(name))) throw new Error('Vendor network or proxy changed after allocation.'); + remaining(); +}; + +export function assertVendorNetwork(network: VendorNetwork, invocation?: InvocationInput, agentName?: string, + timeoutMs = 30_000): void { + validateVendorNetwork(network, invocation, agentName, deadline(timeoutMs)); } export function createVendorNetwork(invocation: InvocationInput, imageId: string, @@ -118,7 +131,7 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string ].join('')], remaining()); const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor }); identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet })); - assertVendorNetwork(network, invocation); + validateVendorNetwork(network, invocation, undefined, remaining); remaining(); return network; } catch (error) { diff --git a/agents/policy.ts b/agents/policy.ts index edd2442..826c856 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -12,17 +12,20 @@ export interface PhasePolicy { export interface AgentCommand { readonly argv: readonly string[] } interface PolicyIdentity { readonly invocation: InvocationInput } const identities = new WeakMap(); -const commands = new WeakMap(); +const commands = new WeakMap(); const command = (policy: PhasePolicy, argv: readonly string[]): AgentCommand => { assertPhasePolicy(policy); const value = Object.freeze({ argv: Object.freeze([...argv]) }); - commands.set(value, policy); + commands.set(value, Object.freeze({ policy, vendor: assertPhasePolicy(policy).vendor })); return value; }; -export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy): readonly string[] { - if (commands.get(value) !== policy) throw new Error('Container command was not generated for this phase policy.'); +export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy, + vendor?: InvocationInput['vendor']): readonly string[] { + const identity = commands.get(value); + if (identity?.policy !== policy || (vendor && identity.vendor !== vendor)) + throw new Error('Container command was not generated for this phase policy and vendor.'); return value.argv; } @@ -59,7 +62,7 @@ export function dispatchApprovedCommand(policy: PhasePolicy, argv: readonly s export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentCommand { if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.'); - assertPhasePolicy(policy); + if (assertPhasePolicy(policy).vendor !== 'claude') throw new Error('Claude command requires a Claude invocation policy.'); const writable = policy.worktree === 'read-write'; const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep'; return command(policy, ['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config', @@ -69,7 +72,7 @@ export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentC } export function codexBaseArguments(policy: PhasePolicy): readonly string[] { - assertPhasePolicy(policy); + if (assertPhasePolicy(policy).vendor !== 'codex') throw new Error('Codex command requires a Codex invocation policy.'); return Object.freeze(['codex', '--strict-config', '--config', 'web_search="disabled"', '--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']); } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index aa4e661..6ad8d84 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -155,7 +155,7 @@ describe('real Docker agent isolation', () => { imageId: AGENT_IMAGE })).toThrow('immutable built image ID'); chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => profile(data, 'planning', 'noop')).toThrow('only one bounded'); - }); + }, 60_000); it('rejects unexpected host mounts and unbounded task volumes after Docker resolves them', () => { const data = fixture(), valid = profile(data, 'planning', 'noop'); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index 003cb14..09ffc4e 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -51,6 +51,7 @@ describe('vendor-only egress', () => { it('rejects a copied network capability', () => { expect(() => createVendorNetwork(invocation, imageId, 0)).toThrow('positive integer'); + expect(() => assertVendorNetwork(network, invocation, undefined, 0)).toThrow('positive integer'); expect(() => removeVendorNetwork({ ...network })).toThrow('trusted network builder'); const otherInvocation = captureInvocation({ ...invocation, attemptId: 'other-network-probe', deadline: Date.now() + 60_000 }); @@ -73,4 +74,23 @@ describe('vendor-only egress', () => { removeVendorNetwork(other); } }, 60_000); + + it('rejects a proxy replaced with a host namespace before launch', () => { + const replacementInvocation = captureInvocation({ ...invocation, attemptId: 'mutated-proxy-probe', + deadline: Date.now() + 60_000 }); + const replacement = createVendorNetwork(replacementInvocation, imageId); + const inspected = JSON.parse(docker('container', 'inspect', replacement.proxyContainer))[0] as + { Config: { Labels: Record } }; + const allocation = inspected.Config.Labels['io.codeboost.egress']; + try { + docker('rm', '--force', replacement.proxyContainer); + docker('run', '--detach', '--name', replacement.proxyContainer, '--read-only', '--user', '10001:10001', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', + '--cpus=.25', '--pid=host', '--network', replacement.name, '--network-alias', 'codeboost-proxy', + '--label', `io.codeboost.egress=${allocation}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS.claude.join(',')}`, + '--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs'); + docker('network', 'connect', 'bridge', replacement.proxyContainer); + expect(() => assertVendorNetwork(replacement, replacementInvocation)).toThrow('network or proxy changed'); + } finally { removeVendorNetwork(replacement); } + }, 60_000); }); diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts index dfc6186..e4e2939 100644 --- a/test/agent-policy.test.ts +++ b/test/agent-policy.test.ts @@ -3,9 +3,9 @@ import { captureInvocation, type InvocationInput, type Phase } from '../agents/c import { assertAgentCommand, assertAgentTool, codexBaseArguments, createClaudeCommand, createCodexCommand, createPhasePolicy, dispatchApprovedCommand } from '../agents/policy.ts'; -const request = (phase: Phase): InvocationInput => captureInvocation({ +const request = (phase: Phase, vendor: 'claude' | 'codex' = 'claude'): InvocationInput => captureInvocation({ clone: { id: 'clone-1', taskId: 'task-1', directory: '/tmp/task', head: 'a'.repeat(40) }, - vendor: 'claude', phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['npm', 'test']], + vendor, phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['npm', 'test']], deadline: 2000, attemptId: `attempt-${phase}`, context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, }, 1000); @@ -45,10 +45,14 @@ describe('agent phase policy', () => { expect(claude).toContain('Read,Glob,Grep'); expect(claude).toContain('Bash,WebFetch,WebSearch,NotebookEdit'); expect(claude).not.toContain('Edit'); - expect(codexBaseArguments(readonly)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"', + const codexPolicy = createPhasePolicy(request('planning', 'codex')); + expect(codexBaseArguments(codexPolicy)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"', '--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']); - const codex = createCodexCommand(readonly, 'Inspect the schema.'); + const codex = createCodexCommand(codexPolicy, 'Inspect the schema.'); expect(codex.argv).toContain('features.shell_tool=false'); - expect(() => assertAgentCommand({ argv: codex.argv }, readonly)).toThrow('not generated'); + expect(() => assertAgentCommand({ argv: codex.argv }, codexPolicy)).toThrow('not generated'); + expect(() => assertAgentCommand(codex, codexPolicy, 'claude')).toThrow('vendor'); + expect(() => createClaudeCommand(codexPolicy, 'Wrong vendor.')).toThrow('Claude invocation'); + expect(() => createCodexCommand(readonly, 'Wrong vendor.')).toThrow('Codex invocation'); }); }); From 5494bb2148b8a4f88f5ffcda7ad1fc870c4ca49a Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 11:47:04 -0700 Subject: [PATCH 16/44] Block agent DNS and harden proxy validation --- agents/container/profile.ts | 3 ++- agents/container/run.ts | 4 +++- agents/network/network.ts | 23 ++++++++++++++++------- test/agent-container.test.ts | 7 ++++++- test/agent-network.test.ts | 17 +++++++++++++++-- test/questions.test.ts | 4 ++-- 6 files changed, 44 insertions(+), 14 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 1c09d22..d90f221 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -187,7 +187,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', '--cpus=1', '--shm-size=16m', - `--network=${options.network.name}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + `--network=${options.network.name}`, '--dns=127.0.0.1', '--env', 'HOME=/home/codeboost', + '--env', `CODEBOOST_PHASE=${invocation.phase}`, '--label', `io.codeboost.invocation=${ownershipId}`, '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', '--env', `HTTPS_PROXY=${options.network.proxyUrl}`, '--env', `HTTP_PROXY=${options.network.proxyUrl}`, diff --git a/agents/container/run.ts b/agents/container/run.ts index 1691b61..9f5a957 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -87,7 +87,7 @@ type Inspect = { CpuPercent: number; IOMaximumBandwidth: number; IOMaximumIOps: number; DeviceCgroupRules: unknown[] | null; StorageOpt?: Record | null; CgroupParent: string; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; - Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; + Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null; Dns: string[] }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; NetworkSettings: { Networks: Record }; }; @@ -131,6 +131,8 @@ export function validateContainer(container: string, profile: ContainerProfile, || host.CpuCount !== 0 || host.CpuPercent !== 0 || host.IOMaximumBandwidth !== 0 || host.IOMaximumIOps !== 0 || host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== '') throw new Error('Container daemon configuration is missing required lockdown.'); + if (JSON.stringify(host.Dns) !== JSON.stringify(['127.0.0.1'])) + throw new Error('Container DNS configuration changed.'); if (JSON.stringify(Object.keys(inspect.NetworkSettings.Networks)) !== JSON.stringify([profile.network.name])) throw new Error('Container network attachment changed.'); const tmpfs = host.Tmpfs ?? {}; diff --git a/agents/network/network.ts b/agents/network/network.ts index 4a8c37e..b82736a 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -15,7 +15,7 @@ export interface VendorNetwork { readonly vendor: InvocationInput['vendor']; } interface NetworkIdentity { readonly allocationId: string; readonly imageId: string; readonly invocation: InvocationInput; - readonly subnet: string } + readonly subnet: string; readonly proxyIp: string } const identities = new WeakMap(); const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); const deadline = (timeoutMs: number) => { @@ -66,13 +66,17 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number; NetworkMode?: string; PidMode?: string; IpcMode?: string; UTSMode?: string; UsernsMode?: string; CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null }; - NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + const image = JSON.parse(docker(['image', 'inspect', identity.imageId], remaining()))[0] as + { Config?: { Env?: string[] } } | undefined; const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], remaining()))[0] as { Internal?: boolean; Driver?: string; Labels?: Record; IPAM?: { Config?: Array<{ Subnet?: string }> }; Containers?: Record } | undefined; const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); const endpoints = Object.values(inspectedNetwork?.Containers ?? {}).map(value => value.Name).sort(); const allowedEndpoints = [network.proxyContainer, ...(agentName ? [agentName] : [])]; + const expectedEnvironment = [...(image?.Config?.Env ?? []), + `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[network.vendor].join(',')}`].sort(); if (!inspect?.State?.Running || inspect.Config?.Image !== identity.imageId || inspect.Config?.User !== '10001:10001' || inspect.Config?.Labels?.['io.codeboost.egress'] !== identity.allocationId || !inspect.HostConfig?.ReadonlyRootfs || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') @@ -85,10 +89,10 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp || inspect.HostConfig.UsernsMode !== '' || inspect.HostConfig.CgroupnsMode !== 'private' || (inspect.HostConfig.Devices?.length ?? 0) !== 0 || (inspect.HostConfig.DeviceRequests?.length ?? 0) !== 0 || JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length - || inspect.Config?.Entrypoint?.[0] !== 'node' + || JSON.stringify(inspect.Config?.Entrypoint) !== JSON.stringify(['node']) || JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs']) - || inspect.Config.Env?.filter(value => value.startsWith('CODEBOOST_ALLOWED_HOSTS=')).length !== 1 - || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[network.vendor].join(',')}`) + || JSON.stringify([...(inspect.Config.Env ?? [])].sort()) !== JSON.stringify(expectedEnvironment) + || inspect.NetworkSettings?.Networks?.[network.name]?.IPAddress !== identity.proxyIp || !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge' || inspectedNetwork.Labels?.['io.codeboost.egress'] !== identity.allocationId || inspectedNetwork.IPAM?.Config?.length !== 1 || inspectedNetwork.IPAM.Config[0]?.Subnet !== identity.subnet @@ -129,8 +133,13 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string "socket.once('connect',()=>{socket.destroy();process.exit(0)});", "socket.once('error',()=>{socket.destroy();if(++attempts===50)process.exit(1);setTimeout(check,20)})};check();", ].join('')], remaining()); - const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor }); - identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet })); + const proxyInspect = JSON.parse(docker(['container', 'inspect', proxyContainer], remaining()))[0] as + { NetworkSettings?: { Networks?: Record } } | undefined; + const proxyIp = proxyInspect?.NetworkSettings?.Networks?.[name]?.IPAddress; + if (!proxyIp || !/^10\.254\.\d{1,3}\.\d{1,3}$/.test(proxyIp)) + throw new Error('Vendor proxy did not receive its expected internal address.'); + const network = Object.freeze({ name, proxyContainer, proxyUrl: `http://${proxyIp}:3128`, vendor }); + identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet, proxyIp })); validateVendorNetwork(network, invocation, undefined, remaining); remaining(); return network; diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 6ad8d84..1ff48bd 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -269,6 +269,11 @@ describe('real Docker agent isolation', () => { expect(state.State.Status).toBe('created'); docker('rm', '--force', valid.name); containers.delete(valid.name); + const dnsArgs = valid.args.map(value => value === '--dns=127.0.0.1' ? '--dns=8.8.8.8' : value); + docker(...dnsArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('DNS configuration'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + const imageIndex = valid.args.indexOf(imageId); const namespaceArgs = [...valid.args.slice(0, imageIndex), '--uts=host', ...valid.args.slice(imageIndex)]; docker(...namespaceArgs); containers.add(valid.name); @@ -309,7 +314,7 @@ describe('real Docker agent isolation', () => { expect(() => prepareTaskFilesystems({ ...data.clone }, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, imageId)).toThrow('trusted clone builder'); - }); + }, 60_000); if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { it('runs the authenticated Codex startup path with isolated writable state', () => { diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index 09ffc4e..f19b17d 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -49,6 +49,16 @@ describe('vendor-only egress', () => { expect(direct.stdout).toBe('000'); }, 60_000); + it('does not forward arbitrary DNS even when the embedded resolver is addressed directly', () => { + const result = spawnSync('docker', ['run', '--rm', `--network=${network.name}`, '--dns=127.0.0.1', + '--entrypoint', 'node', imageId, '-e', [ + "const dns=require('node:dns');dns.setServers(['127.0.0.11']);", + "dns.resolve4('example.com',(error)=>process.exit(error?0:1));", + 'setTimeout(()=>process.exit(0),3000);', + ].join('')], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }); + expect(result.status).toBe(0); + }, 30_000); + it('rejects a copied network capability', () => { expect(() => createVendorNetwork(invocation, imageId, 0)).toThrow('positive integer'); expect(() => assertVendorNetwork(network, invocation, undefined, 0)).toThrow('positive integer'); @@ -75,7 +85,10 @@ describe('vendor-only egress', () => { } }, 60_000); - it('rejects a proxy replaced with a host namespace before launch', () => { + it.each([ + ['host namespace', ['--pid=host']], + ['extra Node environment', ['--env', 'NODE_OPTIONS=--trace-warnings']], + ] as const)('rejects a proxy replaced with %s before launch', (_label, extra) => { const replacementInvocation = captureInvocation({ ...invocation, attemptId: 'mutated-proxy-probe', deadline: Date.now() + 60_000 }); const replacement = createVendorNetwork(replacementInvocation, imageId); @@ -86,7 +99,7 @@ describe('vendor-only egress', () => { docker('rm', '--force', replacement.proxyContainer); docker('run', '--detach', '--name', replacement.proxyContainer, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', - '--cpus=.25', '--pid=host', '--network', replacement.name, '--network-alias', 'codeboost-proxy', + '--cpus=.25', ...extra, '--network', replacement.name, '--network-alias', 'codeboost-proxy', '--label', `io.codeboost.egress=${allocation}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS.claude.join(',')}`, '--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs'); docker('network', 'connect', 'bridge', replacement.proxyContainer); diff --git a/test/questions.test.ts b/test/questions.test.ts index ab6a25e..08cd9dd 100644 --- a/test/questions.test.ts +++ b/test/questions.test.ts @@ -7,8 +7,8 @@ import { ReviewService } from '../runner/review.ts'; import { Questions } from '../runner/questions.ts'; import { choiceKeys } from '../core/approvals.ts'; import { agentArguments } from '../runner/question-agent.ts'; -// Real-Git context reads match the existing review integration suite budget. -vi.setConfig({testTimeout:15000}); +// Real-Git context reads can overlap the Docker-backed isolation suite in a full run. +vi.setConfig({testTimeout:30000}); const roots:string[]=[], services:ReviewService[]=[], managers:Questions[]=[]; afterEach(async()=>{for(const manager of managers.splice(0))await manager.close();services.splice(0).forEach(s=>s.close());roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}));vi.restoreAllMocks();}); function waitForAbort(_prompt:string,signal:AbortSignal):Promise{return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));} From 7acc50daa8507ae2797ceb157aba180bbfadf09b Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 12:11:55 -0700 Subject: [PATCH 17/44] Close remaining network lifecycle gaps --- agents/container/profile.ts | 10 +++++++-- agents/container/run.ts | 32 +++++++++++++++++++++++------ agents/network/network.ts | 18 +++++++++++++--- test/agent-container.test.ts | 40 ++++++++++++++++++++++++++++++------ test/agent-network.test.ts | 3 +++ 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index d90f221..5de78c8 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import type { InvocationInput, Phase } from '../contract.ts'; import { assertBuiltAgentImage } from './image.ts'; import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts'; -import { assertVendorNetwork, type VendorNetwork } from '../network/network.ts'; +import { assertVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../network/network.ts'; import { assertAgentCommand, assertPhasePolicy, type AgentCommand, type PhasePolicy } from '../policy.ts'; export interface ContainerProfile { readonly name: string; @@ -51,6 +51,7 @@ interface ProfileIdentity { readonly inputDirectory: string; readonly schema: Fi type InputIdentity = Pick; interface InputCapture extends InputIdentity { readonly content: Buffer } const identities = new WeakMap(); +const claimedNetworks = new WeakSet(); const removeOwnedDirectory = (directory: string) => { if (!lstatSync(directory, { throwIfNoEntry: false })) return; chmodSync(directory, 0o700); @@ -127,7 +128,10 @@ export function assertContainerProfile(profile: ContainerProfile, timeoutMs = 30 export function disposeContainerProfile(profile: ContainerProfile): void { const identity = identities.get(profile); if (!identity) return; - removeOwnedDirectories(identity.cleanupDirectories); + const failures: unknown[] = []; + try { removeOwnedDirectories(identity.cleanupDirectories); } catch (error) { failures.push(error); } + try { removeVendorNetwork(identity.network); } catch (error) { failures.push(error); } + if (failures.length) throw new AggregateError(failures, 'Profile resource cleanup did not settle.'); identities.delete(profile); } @@ -149,6 +153,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil assertBuiltAgentImage(options.imageId); assertTaskFilesystems(filesystems, invocation.clone); assertVendorNetwork(options.network, invocation); + if (claimedNetworks.has(options.network)) throw new Error('Vendor network already belongs to another container profile.'); assertPhasePolicy(options.policy, invocation); const command = assertAgentCommand(options.command, options.policy, invocation.vendor); const sourceInput = captureInput(options.inputDirectory); @@ -216,6 +221,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil auth: authIdentity, cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone, network: options.network, policy: options.policy, invocation })); + claimedNetworks.add(options.network); return profile; } catch (error) { try { removeOwnedDirectories(cleanupDirectories); } diff --git a/agents/container/run.ts b/agents/container/run.ts index 9f5a957..9b3e36a 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -87,9 +87,11 @@ type Inspect = { CpuPercent: number; IOMaximumBandwidth: number; IOMaximumIOps: number; DeviceCgroupRules: unknown[] | null; StorageOpt?: Record | null; CgroupParent: string; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; - Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null; Dns: string[] }; + Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null; Dns: string[]; + DnsOptions: string[]; DnsSearch: string[]; ExtraHosts: string[] | null; + PortBindings: Record | null; PublishAllPorts: boolean }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; - NetworkSettings: { Networks: Record }; + NetworkSettings: { Networks: Record; Ports: Record }; }; /** Validate daemon-resolved configuration before starting an agent. */ @@ -133,6 +135,10 @@ export function validateContainer(container: string, profile: ContainerProfile, throw new Error('Container daemon configuration is missing required lockdown.'); if (JSON.stringify(host.Dns) !== JSON.stringify(['127.0.0.1'])) throw new Error('Container DNS configuration changed.'); + if (host.DnsOptions.length || host.DnsSearch.length || (host.ExtraHosts?.length ?? 0) + || Object.keys(host.PortBindings ?? {}).length || host.PublishAllPorts + || Object.keys(inspect.NetworkSettings.Ports ?? {}).length) + throw new Error('Container host or port configuration changed.'); if (JSON.stringify(Object.keys(inspect.NetworkSettings.Networks)) !== JSON.stringify([profile.network.name])) throw new Error('Container network attachment changed.'); const tmpfs = host.Tmpfs ?? {}; @@ -253,14 +259,14 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs = } } -export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, +export function startValidatedContainer(profile: ContainerProfile, timeoutMs = 60_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); - const container = createValidatedContainer(profile, remaining(), secrets); let failure: unknown; try { - assertContainerProfile(profile, remaining()); - const output = docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); + validateSecrets(profile, secrets); + validateContainer(profile.name, profile, remaining()); + const output = docker(['start', '--attach', profile.name], { timeoutMs: remaining(), secrets }); remaining(); return output; } @@ -273,3 +279,17 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, } } } + +export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, + secrets: Readonly> = {}): string { + const remaining = createDeadline(timeoutMs); + createValidatedContainer(profile, remaining(), secrets); + let startBudget: number; + try { startBudget = remaining(); } + catch (error) { + try { removeContainerOrThrow(profile); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Agent deadline and cleanup both failed.'); } + throw error; + } + return startValidatedContainer(profile, startBudget, secrets); +} diff --git a/agents/network/network.ts b/agents/network/network.ts index b82736a..0cda964 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -17,6 +17,7 @@ export interface VendorNetwork { interface NetworkIdentity { readonly allocationId: string; readonly imageId: string; readonly invocation: InvocationInput; readonly subnet: string; readonly proxyIp: string } const identities = new WeakMap(); +const removedNetworks = new WeakSet(); const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); const deadline = (timeoutMs: number) => { if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Network deadline must be a positive integer.'); @@ -65,8 +66,11 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number; NetworkMode?: string; PidMode?: string; IpcMode?: string; UTSMode?: string; UsernsMode?: string; - CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null }; - NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null; + Dns?: string[]; DnsOptions?: string[]; DnsSearch?: string[]; ExtraHosts?: string[] | null; + PortBindings?: Record | null; PublishAllPorts?: boolean }; + NetworkSettings?: { Networks?: Record; Ports?: Record }; + Mounts?: unknown[] } | undefined; const image = JSON.parse(docker(['image', 'inspect', identity.imageId], remaining()))[0] as { Config?: { Env?: string[] } } | undefined; const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], remaining()))[0] as @@ -88,6 +92,10 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp || inspect.HostConfig.IpcMode !== 'private' || inspect.HostConfig.UTSMode !== '' || inspect.HostConfig.UsernsMode !== '' || inspect.HostConfig.CgroupnsMode !== 'private' || (inspect.HostConfig.Devices?.length ?? 0) !== 0 || (inspect.HostConfig.DeviceRequests?.length ?? 0) !== 0 + || (inspect.HostConfig.Dns?.length ?? 0) !== 0 || (inspect.HostConfig.DnsOptions?.length ?? 0) !== 0 + || (inspect.HostConfig.DnsSearch?.length ?? 0) !== 0 || (inspect.HostConfig.ExtraHosts?.length ?? 0) !== 0 + || Object.keys(inspect.HostConfig.PortBindings ?? {}).length !== 0 || inspect.HostConfig.PublishAllPorts + || Object.keys(inspect.NetworkSettings?.Ports ?? {}).length !== 0 || JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length || JSON.stringify(inspect.Config?.Entrypoint) !== JSON.stringify(['node']) || JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs']) @@ -156,7 +164,10 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string export function removeVendorNetwork(network: VendorNetwork): void { const identity = identities.get(network); - if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); + if (!identity) { + if (removedNetworks.has(network)) return; + throw new Error('Vendor network was not created by the trusted network builder.'); + } assertBuiltAgentImage(identity.imageId); const allocationId = identity.allocationId; const remaining = deadline(30_000), failures: unknown[] = []; @@ -166,4 +177,5 @@ export function removeVendorNetwork(network: VendorNetwork): void { remaining, 'vendor network', allocationId); } catch (error) { failures.push(error); } if (failures.length) throw new AggregateError(failures, 'Vendor network cleanup did not settle.'); identities.delete(network); + removedNetworks.add(network); } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 1ff48bd..15631a7 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -7,7 +7,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; import { AGENT_IMAGE, assertBuiltAgentImage, buildAgentImage } from '../agents/container/image.ts'; import { createContainerProfile, disposeContainerProfile } from '../agents/container/profile.ts'; -import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, +import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, startValidatedContainer, hasExactOptions, validateContainer } from '../agents/container/run.ts'; import { createTaskClone } from '../git/clone.ts'; import { createVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../agents/network/network.ts'; @@ -96,6 +96,13 @@ describe('real Docker agent isolation', () => { expect(runContainer(profile(data, phase, 'phase-worktree'))).toBe(''); }, 60_000); + it('removes the invocation proxy and network after the container settles', () => { + const data = fixture(), valid = profile(data, 'planning', 'noop'); + expect(runContainer(valid)).toBe(''); + expect(spawnSync('docker', ['container', 'inspect', valid.network.proxyContainer]).status).not.toBe(0); + expect(spawnSync('docker', ['network', 'inspect', valid.network.name]).status).not.toBe(0); + }, 60_000); + it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { const data = fixture(); process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; @@ -230,10 +237,10 @@ describe('real Docker agent isolation', () => { it('does not remove an active container when a duplicate attempt name collides', () => { const data = fixture(), captured = invocation(data.clone, 'planning'); - const common = governed(captured); - const first = createContainerProfile({ ...common, filesystems: data.filesystems, + const duplicateInvocation = captureInvocation({ ...captured }); + const first = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId }); - const duplicate = createContainerProfile({ ...common, filesystems: data.filesystems, + const duplicate = createContainerProfile({ ...governed(duplicateInvocation), filesystems: data.filesystems, inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId }); profiles.push(first, duplicate); docker(...first.args); containers.add(first.name); @@ -274,6 +281,14 @@ describe('real Docker agent isolation', () => { expect(() => validateContainer(valid.name, valid)).toThrow('DNS configuration'); docker('rm', '--force', valid.name); containers.delete(valid.name); + for (const extra of [['--add-host=api.openai.com:127.0.0.1'], ['--publish=127.0.0.1::3128']]) { + const changedArgs = [...valid.args.slice(0, valid.args.indexOf(imageId)), ...extra, + ...valid.args.slice(valid.args.indexOf(imageId))]; + docker(...changedArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('host or port configuration'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + } + const imageIndex = valid.args.indexOf(imageId); const namespaceArgs = [...valid.args.slice(0, imageIndex), '--uts=host', ...valid.args.slice(imageIndex)]; docker(...namespaceArgs); containers.add(valid.name); @@ -292,10 +307,23 @@ describe('real Docker agent isolation', () => { try { docker('run', '--detach', '--name', rogue, `--network=${valid.network.name}`, '--entrypoint', 'node', imageId, '-e', 'setInterval(()=>{},1000)'); - expect(() => createValidatedContainer(valid)).toThrow('network or proxy changed'); + expect(() => createValidatedContainer(valid)).toThrow('cleanup did not settle'); const absent = spawnSync('docker', ['container', 'inspect', valid.name], { encoding: 'utf8' }); expect(absent.status).not.toBe(0); - } finally { spawnSync('docker', ['rm', '--force', rogue], { stdio: 'ignore' }); } + } finally { + spawnSync('docker', ['rm', '--force', rogue], { stdio: 'ignore' }); + disposeContainerProfile(valid); + } + }, 60_000); + + it('revalidates the agent attachment immediately before start', () => { + const data = fixture(), valid = profile(data, 'planning', 'must-not-run'); + createValidatedContainer(valid); containers.add(valid.name); + docker('network', 'disconnect', valid.network.name, valid.name); + docker('network', 'connect', 'bridge', valid.name); + expect(() => startValidatedContainer(valid)).toThrow(/network attachment|lockdown/); + containers.delete(valid.name); + expect(spawnSync('docker', ['container', 'inspect', valid.name]).status).not.toBe(0); }, 60_000); it('creates containers from the captured immutable image rather than its mutable tag', () => { diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index f19b17d..9e373f5 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -88,6 +88,9 @@ describe('vendor-only egress', () => { it.each([ ['host namespace', ['--pid=host']], ['extra Node environment', ['--env', 'NODE_OPTIONS=--trace-warnings']], + ['DNS override', ['--dns=8.8.8.8']], + ['host override', ['--add-host=api.anthropic.com:127.0.0.1']], + ['published proxy port', ['--publish=127.0.0.1::3128']], ] as const)('rejects a proxy replaced with %s before launch', (_label, extra) => { const replacementInvocation = captureInvocation({ ...invocation, attemptId: 'mutated-proxy-probe', deadline: Date.now() + 60_000 }); From 981e831637c1d19c3b4c759e18cc5ec06e8a5f33 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 12:21:53 -0700 Subject: [PATCH 18/44] Stabilize live Claude marker probe --- test/agent-container.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 15631a7..7ab8a6d 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -370,7 +370,7 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); const envelope = JSON.parse(output) as { result?: string; is_error?: boolean }; expect(envelope.is_error).not.toBe(true); - expect(envelope.result?.trim()).toBe('codeboost-schema-marker'); + expect(envelope.result).toContain('codeboost-schema-marker'); }, 6 * 60_000); } }); From 682e6ae7fb1d23519a5b42974747404352256d14 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 16:32:55 -0700 Subject: [PATCH 19/44] Add bounded production agent adapters --- .github/workflows/agent-isolation.yml | 2 +- agents/adapters/claude.ts | 29 +++ agents/adapters/codex.ts | 29 +++ agents/adapters/supervisor.ts | 277 ++++++++++++++++++++++++++ agents/adapters/types.ts | 15 ++ agents/container/probe.sh | 13 ++ agents/container/profile.ts | 9 +- agents/container/run.ts | 8 + agents/policy.ts | 14 +- test/agent-adapter.test.ts | 34 ++++ test/agent-supervisor.test.ts | 175 ++++++++++++++++ 11 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 agents/adapters/claude.ts create mode 100644 agents/adapters/codex.ts create mode 100644 agents/adapters/supervisor.ts create mode 100644 agents/adapters/types.ts create mode 100644 test/agent-adapter.test.ts create mode 100644 test/agent-supervisor.test.ts diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index c818679..ad60d91 100644 --- a/.github/workflows/agent-isolation.yml +++ b/.github/workflows/agent-isolation.yml @@ -22,4 +22,4 @@ jobs: cache: npm - run: npm ci --ignore-scripts - run: npm run typecheck - - run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts test/agent-network.test.ts test/agent-policy.test.ts + - run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts test/agent-network.test.ts test/agent-policy.test.ts test/agent-adapter.test.ts test/agent-supervisor.test.ts diff --git a/agents/adapters/claude.ts b/agents/adapters/claude.ts new file mode 100644 index 0000000..41c7cc3 --- /dev/null +++ b/agents/adapters/claude.ts @@ -0,0 +1,29 @@ +import type { InvocationHandle } from '../contract.ts'; +import { createContainerProfile } from '../container/profile.ts'; +import { createVendorNetwork, removeVendorNetwork } from '../network/network.ts'; +import { createClaudeCommand, createPhasePolicy } from '../policy.ts'; +import { startProfileInvocation } from './supervisor.ts'; +import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; + +export function parseClaudeOutput(raw: Buffer): { text: string; providerFailed: boolean } { + const envelope = JSON.parse(raw.toString('utf8')) as { result?: unknown; is_error?: unknown }; + if (typeof envelope.result !== 'string' || typeof envelope.is_error !== 'boolean') + throw new Error('Claude returned a malformed output envelope.'); + return Object.freeze({ text: envelope.result, providerFailed: envelope.is_error }); +} + +export function startClaudeInvocation(request: AgentAdapterRequest, + oauthToken: string, options: AgentAdapterOptions = {}): InvocationHandle { + if (!oauthToken || oauthToken.includes('\0')) throw new Error('Claude OAuth token is malformed.'); + const policy = createPhasePolicy(request.invocation); + const network = createVendorNetwork(request.invocation, request.imageId); + try { + const profile = createContainerProfile({ ...request, policy, network, + command: createClaudeCommand(policy, request.prompt), claudeToken: oauthToken }); + return startProfileInvocation(profile, { ...options, secrets: { CLAUDE_CODE_OAUTH_TOKEN: oauthToken }, + decode: (_profile, raw) => parseClaudeOutput(raw) }); + } catch (error) { + removeVendorNetwork(network); + throw error; + } +} diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts new file mode 100644 index 0000000..deef695 --- /dev/null +++ b/agents/adapters/codex.ts @@ -0,0 +1,29 @@ +import type { InvocationHandle } from '../contract.ts'; +import { createContainerProfile } from '../container/profile.ts'; +import { createVendorNetwork, removeVendorNetwork } from '../network/network.ts'; +import { createCodexCommand, createPhasePolicy } from '../policy.ts'; +import { readBoundedContainerFile, startProfileInvocation } from './supervisor.ts'; +import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; + +export const CODEX_OUTPUT_FILE = '/tmp/codeboost-output/final.txt'; + +export function readCodexOutput(container: string, maximumBytes: number) { + const output = readBoundedContainerFile(container, CODEX_OUTPUT_FILE, maximumBytes); + return Object.freeze({ text: output.toString('utf8'), additionalBytes: output.length }); +} + +export function startCodexInvocation(request: AgentAdapterRequest, + authFile: string, options: AgentAdapterOptions = {}): InvocationHandle { + if (!authFile || authFile.includes('\0')) throw new Error('Codex auth path is malformed.'); + const policy = createPhasePolicy(request.invocation); + const network = createVendorNetwork(request.invocation, request.imageId); + try { + const profile = createContainerProfile({ ...request, policy, network, + command: createCodexCommand(policy, request.prompt), codexAuthFile: authFile, deferredOutput: true }); + return startProfileInvocation(profile, { ...options, + decode: (current, _raw, maximum) => readCodexOutput(current.name, maximum) }); + } catch (error) { + removeVendorNetwork(network); + throw error; + } +} diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts new file mode 100644 index 0000000..d1d84c3 --- /dev/null +++ b/agents/adapters/supervisor.ts @@ -0,0 +1,277 @@ +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import type { InvocationHandle, InvocationResult, StopReason } from '../contract.ts'; +import { assertPhasePolicy } from '../policy.ts'; +import { createValidatedContainer, disposeValidatedContainer, validateContainer } from '../container/run.ts'; +import type { ContainerProfile } from '../container/profile.ts'; + +export const OUTPUT_LIMITS = Object.freeze({ + stdoutBytes: 16 * 1024 * 1024, + stderrBytes: 4 * 1024 * 1024, + combinedBytes: 20 * 1024 * 1024, +}); +const DEFAULT_TIMEOUT_MS = 10 * 60_000; +const DIAGNOSTIC_BYTES = 1024; +const active = new Map(); + +export interface CaptureLimits { + readonly stdoutBytes: number; + readonly stderrBytes: number; + readonly combinedBytes: number; +} +export interface DecodedOutput { + readonly text: string; + /** Bytes captured outside process stdout, such as Codex's final-output file. */ + readonly additionalBytes?: number; + readonly providerFailed?: boolean; +} +export interface SupervisorOptions { + readonly secrets?: Readonly>; + readonly timeoutMs?: number; + readonly limits?: Partial; + readonly decode?: (profile: ContainerProfile, rawStdout: Buffer, maximumBytes: number) => DecodedOutput; +} +export class OutputLimitError extends Error {} + +const dockerEnvironment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); +const positiveInteger = (value: number, name: string) => { + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`); +}; +const captureLimits = (override: Partial | undefined): CaptureLimits => { + const limits = Object.freeze({ ...OUTPUT_LIMITS, ...override }); + positiveInteger(limits.stdoutBytes, 'stdoutBytes'); + positiveInteger(limits.stderrBytes, 'stderrBytes'); + positiveInteger(limits.combinedBytes, 'combinedBytes'); + if (limits.stdoutBytes > OUTPUT_LIMITS.stdoutBytes || limits.stderrBytes > OUTPUT_LIMITS.stderrBytes + || limits.combinedBytes > OUTPUT_LIMITS.combinedBytes) + throw new Error('Capture limits cannot exceed the production hard limits.'); + return limits; +}; +const diagnosticFor = (reason: StopReason, detail?: string) => Buffer.from( + `[codeboost: ${reason}${detail ? `: ${detail.replace(/[\r\n]+/g, ' ').slice(0, 512)}` : ''}]\n`); +const withDiagnostic = (stderr: Buffer, stdoutBytes: number, reason: StopReason, limits: CaptureLimits, + detail?: string) => { + const diagnostic = diagnosticFor(reason, detail).subarray(0, DIAGNOSTIC_BYTES); + const maximum = Math.max(0, Math.min(limits.stderrBytes, limits.combinedBytes - stdoutBytes)); + if (maximum <= diagnostic.length) return diagnostic.subarray(0, maximum); + return Buffer.concat([stderr.subarray(0, maximum - diagnostic.length), diagnostic]); +}; +const runControl = (args: readonly string[], timeoutMs = 5_000): ChildProcess => { + const child = spawn('docker', [...args], { env: dockerEnvironment(), stdio: 'ignore' }); + const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + timer.unref(); + child.once('close', () => clearTimeout(timer)); + child.once('error', () => clearTimeout(timer)); + return child; +}; + +/** Read a running container's tmpfs file with a pinned no-follow bounded reader. */ +export function readBoundedContainerFile(container: string, source: string, maximumBytes: number): Buffer { + positiveInteger(maximumBytes, 'maximumBytes'); + if (!source.startsWith('/tmp/codeboost-output/') || source.includes('\0')) + throw new Error('Adapter output must come from the bounded output directory.'); + try { + const reader = [ + "const fs=require('node:fs'),path=process.argv[1],maximum=Number(process.argv[2]);", + 'let fd;try{fd=fs.openSync(path,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);', + "const before=fs.fstatSync(fd);if(before.size>maximum)throw new Error('OUTPUT_LIMIT');", + "if(!before.isFile()||before.nlink!==1)throw new Error('UNSAFE_FILE');", + 'const output=Buffer.allocUnsafe(maximum+1);let length=0,count=0;', + 'do{count=fs.readSync(fd,output,length,output.length-length,null);length+=count}', + "while(count>0&&lengthmaximum)throw new Error('OUTPUT_LIMIT');", + 'const after=fs.fstatSync(fd);if(before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size', + "||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)throw new Error('CHANGED_FILE');", + 'process.stdout.write(output.subarray(0,length))}finally{if(fd!==undefined)fs.closeSync(fd)}', + ].join(''); + return execFileSync('docker', ['exec', container, 'node', '-e', reader, source, String(maximumBytes)], { + env: dockerEnvironment(), timeout: 30_000, killSignal: 'SIGKILL', maxBuffer: maximumBytes + 1, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const diagnostic = error && typeof error === 'object' && 'stderr' in error ? String(error.stderr) : String(error); + if (diagnostic.includes('OUTPUT_LIMIT')) throw new OutputLimitError('Adapter output exceeds its capture limit.'); + throw new Error('Adapter output is not a stable bounded unlinked regular file.'); + } +} + +export function isInvocationActive(attemptId: string): boolean { + return active.has(attemptId); +} + +export function startProfileInvocation(profile: ContainerProfile, options: SupervisorOptions = {}): InvocationHandle { + const invocation = assertPhasePolicy(profile.policy); + if (active.has(invocation.attemptId)) { + disposeValidatedContainer(profile); + throw new Error('An invocation with this attempt ID is still active.'); + } + let limits: CaptureLimits, configuredTimeout: number; + try { + limits = captureLimits(options.limits); + configuredTimeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + positiveInteger(configuredTimeout, 'timeoutMs'); + } catch (error) { + disposeValidatedContainer(profile); + throw error; + } + const now = Date.now(), deadline = Math.min(invocation.deadline, now + configuredTimeout); + if (!Number.isSafeInteger(deadline) || deadline <= now) { + disposeValidatedContainer(profile); + throw new Error('Invocation deadline has already expired.'); + } + const remaining = () => { + const value = deadline - Date.now(); + if (value < 1) throw new Error('Invocation deadline has already expired.'); + return value; + }; + try { + createValidatedContainer(profile, remaining(), options.secrets ?? {}); + validateContainer(profile.name, profile, remaining()); + } catch (error) { + try { disposeValidatedContainer(profile); } catch { /* createValidatedContainer already reports unsettled cleanup */ } + throw error; + } + + const stdoutChunks: Buffer[] = [], stderrChunks: Buffer[] = []; + let stdoutBytes = 0, stderrBytes = 0, combinedBytes = 0; + let stopReason: StopReason | undefined, failureDetail: string | undefined, closed = false, terminating = false; + let decodedOutput: DecodedOutput | undefined, protocolToken: string | undefined; + let protocolBuffer = Buffer.alloc(0); + const child = spawn('docker', ['start', '--attach', profile.name], { + env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + }); + const timers = new Set>(); + const later = (callback: () => void, delay: number) => { + const timer = setTimeout(() => { timers.delete(timer); callback(); }, delay); + timer.unref(); timers.add(timer); return timer; + }; + const terminate = () => { + if (terminating || closed) return; + terminating = true; + child.stdout?.resume(); child.stderr?.resume(); + runControl(['stop', '--signal=TERM', '--time=1', profile.name]); + later(() => { if (!closed) runControl(['kill', '--signal=KILL', profile.name]); }, 1_500); + later(() => { + if (!closed) { + runControl(['rm', '--force', profile.name]); + child.kill('SIGKILL'); + } + }, 4_000); + }; + const stop = (reason: StopReason) => { + if (closed || stopReason) return; + stopReason = reason; + terminate(); + }; + const capture = (stream: 'stdout' | 'stderr', value: Buffer | string) => { + if (stopReason || closed) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + const streamBytes = stream === 'stdout' ? stdoutBytes : stderrBytes; + const streamLimit = stream === 'stdout' ? limits.stdoutBytes : limits.stderrBytes; + const available = Math.max(0, Math.min(streamLimit - streamBytes, limits.combinedBytes - combinedBytes)); + if (available > 0) { + const retained = chunk.subarray(0, available); + (stream === 'stdout' ? stdoutChunks : stderrChunks).push(retained); + if (stream === 'stdout') stdoutBytes += retained.length; + else stderrBytes += retained.length; + combinedBytes += retained.length; + } + if (chunk.length > available) stop('output-limit'); + }; + const decodeOutput = () => { + if (!options.decode || decodedOutput || stopReason) return; + try { + const raw = Buffer.concat(stdoutChunks, stdoutBytes); + const decoded = options.decode(profile, raw, + Math.max(1, Math.min(limits.stdoutBytes - stdoutBytes, limits.combinedBytes - combinedBytes))); + const additional = decoded.additionalBytes ?? 0; + if (!Number.isSafeInteger(additional) || additional < 0) throw new Error('Adapter returned an invalid byte count.'); + if (stdoutBytes + additional > limits.stdoutBytes || combinedBytes + additional > limits.combinedBytes) + throw new OutputLimitError('Adapter output exceeds its capture limit.'); + decodedOutput = decoded; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const reason = error instanceof OutputLimitError || /exceeds its capture limit/i.test(message) + ? 'output-limit' : 'capture-failure'; + failureDetail ??= message; + if (closed) stopReason ??= reason; + else stop(reason); + } + }; + const protocolLine = (line: Buffer) => { + const text = line.toString('utf8').trim(); + const started = /^\x1eCODEBOOST_START:([0-9a-f-]{36})\x1e$/.exec(text); + if (started) { + if (protocolToken && protocolToken !== started[1]) return false; + protocolToken = started[1]; + return true; + } + const ready = /^\x1eCODEBOOST_READY:([0-9a-f-]{36}):([0-9]+)\x1e$/.exec(text); + if (!ready || ready[1] !== protocolToken) return false; + decodeOutput(); + if (decodedOutput) runControl(['exec', profile.name, 'touch', `/tmp/codeboost-output/collected-${ready[1]}`]); + return true; + }; + const captureStderr = (value: Buffer | string) => { + if (!profile.deferredOutput) { capture('stderr', value); return; } + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + protocolBuffer = Buffer.concat([protocolBuffer, chunk]); + let newline: number; + while ((newline = protocolBuffer.indexOf(0x0a)) >= 0) { + const line = protocolBuffer.subarray(0, newline + 1); + protocolBuffer = protocolBuffer.subarray(newline + 1); + if (!protocolLine(line)) capture('stderr', line); + } + if (protocolBuffer.length > 1024) { + const flush = protocolBuffer.subarray(0, protocolBuffer.length - 128); + protocolBuffer = protocolBuffer.subarray(protocolBuffer.length - 128); + capture('stderr', flush); + } + }; + child.stdout?.on('data', value => capture('stdout', value)); + child.stderr?.on('data', captureStderr); + child.stdout?.once('error', error => { failureDetail ??= error.message; stop('capture-failure'); }); + child.stderr?.once('error', error => { failureDetail ??= error.message; stop('capture-failure'); }); + child.once('error', error => { failureDetail ??= error.message; stop('capture-failure'); }); + later(() => stop('timeout'), Math.max(1, deadline - Date.now())); + + let resolveSettled!: (result: InvocationResult) => void; + const settled = new Promise(resolve => { resolveSettled = resolve; }); + const handle: InvocationHandle = Object.freeze({ + attemptId: invocation.attemptId, + settled, + cancel: (reason: StopReason) => stop(reason), + }); + active.set(invocation.attemptId, handle); + + child.once('close', (code, signal) => { + for (const timer of timers) clearTimeout(timer); + timers.clear(); + if (protocolBuffer.length) { + if (!protocolLine(protocolBuffer)) capture('stderr', protocolBuffer); + protocolBuffer = Buffer.alloc(0); + } + closed = true; + let finalStdout = Buffer.concat(stdoutChunks, stdoutBytes), finalStderr = Buffer.concat(stderrChunks, stderrBytes); + let exitCode = code, finalSignal = signal; + if (!stopReason && code === 0 && options.decode && !profile.deferredOutput) decodeOutput(); + if (!stopReason && code === 0 && profile.deferredOutput && !decodedOutput) { + stopReason = 'capture-failure'; failureDetail ??= 'Deferred output protocol did not complete.'; + } + if (decodedOutput) { + finalStdout = Buffer.from(decodedOutput.text); + if (decodedOutput.providerFailed) exitCode = exitCode === 0 ? 1 : exitCode; + } + try { + disposeValidatedContainer(profile); + } catch { + stopReason ??= 'capture-failure'; + return; // Ownership remains active because termination/cleanup was not confirmed. + } + if (stopReason) finalStderr = withDiagnostic(finalStderr, finalStdout.length, stopReason, limits, failureDetail); + const result = Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, + exitCode, signal: finalSignal, ...(stopReason ? { stopReason } : {}), + stdout: finalStdout.toString('utf8'), stderr: finalStderr.toString('utf8') }); + active.delete(invocation.attemptId); + resolveSettled(result); + }); + return handle; +} diff --git a/agents/adapters/types.ts b/agents/adapters/types.ts new file mode 100644 index 0000000..ab942c7 --- /dev/null +++ b/agents/adapters/types.ts @@ -0,0 +1,15 @@ +import type { InvocationInput } from '../contract.ts'; +import type { TaskFilesystems } from '../container/storage.ts'; +import type { CaptureLimits } from './supervisor.ts'; + +export interface AgentAdapterRequest { + readonly invocation: InvocationInput; + readonly filesystems: TaskFilesystems; + readonly inputDirectory: string; + readonly imageId: string; + readonly prompt: string; +} +export interface AgentAdapterOptions { + readonly timeoutMs?: number; + readonly limits?: Partial; +} diff --git a/agents/container/probe.sh b/agents/container/probe.sh index df56239..1a6e91f 100644 --- a/agents/container/probe.sh +++ b/agents/container/probe.sh @@ -78,4 +78,17 @@ esac [ "$(codex --version)" = 'codex-cli 0.153.4' ] || fail 'unexpected Codex version' [ "$(claude --version | awk '{print $1}')" = '2.1.281' ] || fail 'unexpected Claude version' +install --directory --owner=10001 --group=10001 --mode=0700 /tmp/codeboost-output +if [ "${CODEBOOST_DEFERRED_OUTPUT:-}" = '1' ]; then + token="$(cat /proc/sys/kernel/random/uuid)" + printf '\036CODEBOOST_START:%s\036\n' "$token" >&2 + set +e + "$@" + status="$?" + set -e + printf '\036CODEBOOST_READY:%s:%s\036\n' "$token" "$status" >&2 + acknowledgement="/tmp/codeboost-output/collected-$token" + while [ ! -e "$acknowledgement" ]; do sleep 0.05; done + exit "$status" +fi exec "$@" diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 5de78c8..dead868 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -21,6 +21,7 @@ export interface ContainerProfile { readonly ownershipId: string; readonly network: VendorNetwork; readonly policy: PhasePolicy; + readonly deferredOutput: boolean; } export interface ProfileOptions { readonly invocation: InvocationInput; @@ -32,6 +33,7 @@ export interface ProfileOptions { readonly claudeToken?: string; readonly network: VendorNetwork; readonly policy: PhasePolicy; + readonly deferredOutput?: boolean; } interface FileIdentity { @@ -206,6 +208,10 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil '--mount', mount({ type: 'volume', source: filesystems.workVolume, target: '/work', readonly: readOnlyWork }), '--mount', mount({ type: 'volume', source: filesystems.metadataVolume, target: '/work/.git', readonly: true }), '--mount', mount({ type: 'bind', source: inputIdentity.inputDirectory, target: '/run/codeboost-input', readonly: true })]; + if (options.deferredOutput) { + if (invocation.vendor !== 'codex') throw new Error('Deferred output is available only for Codex.'); + args.push('--env', 'CODEBOOST_DEFERRED_OUTPUT=1'); + } if (invocation.vendor === 'codex') { args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', @@ -216,7 +222,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile, - command: Object.freeze([...command]), ownershipId, network: options.network, policy: options.policy }); + command: Object.freeze([...command]), ownershipId, network: options.network, policy: options.policy, + deferredOutput: options.deferredOutput === true }); identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema, auth: authIdentity, cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone, diff --git a/agents/container/run.ts b/agents/container/run.ts index 9b3e36a..3bfb5b8 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -72,6 +72,11 @@ const removeContainerOrThrow = (profile: ContainerProfile) => { disposeContainerProfile(profile); }; +/** Remove a validated invocation container, then its profile-owned staging and network resources. */ +export function disposeValidatedContainer(profile: ContainerProfile): void { + removeContainerOrThrow(profile); +} + type Inspect = { Image: string; Config: { Image: string; User: string; Env: string[]; Entrypoint: string[] | null; Cmd: string[] | null; @@ -216,6 +221,7 @@ export function validateContainer(container: string, profile: ContainerProfile, const allowedEnvironment = new Set(['PATH', 'NODE_VERSION', 'YARN_VERSION', 'HOME', 'CODEBOOST_PHASE', 'CODEBOOST_VENDOR', 'CODEBOOST_WORK_BYTES', 'CODEBOOST_WORK_INODES', 'CODEBOOST_METADATA_BYTES', 'CODEBOOST_METADATA_INODES', 'npm_config_cache', 'XDG_CACHE_HOME', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', + ...(profile.deferredOutput ? ['CODEBOOST_DEFERRED_OUTPUT'] : []), ...(profile.vendor === 'codex' ? ['CODEX_HOME'] : ['CLAUDE_CODE_OAUTH_TOKEN'])]); if (new Set(names).size !== names.length || names.some(name => !allowedEnvironment.has(name))) throw new Error('Container includes an unexpected environment variable.'); @@ -232,6 +238,8 @@ export function validateContainer(container: string, profile: ContainerProfile, || environment.get('HTTP_PROXY') !== profile.network.proxyUrl || environment.get('NO_PROXY') !== 'localhost,127.0.0.1') throw new Error('Container isolation environment changed.'); + if (profile.deferredOutput && environment.get('CODEBOOST_DEFERRED_OUTPUT') !== '1') + throw new Error('Container deferred-output protocol changed.'); if (profile.vendor === 'codex' && (names.includes('CLAUDE_CODE_OAUTH_TOKEN') || environment.get('CODEX_HOME') !== '/run/codeboost-auth/codex')) throw new Error('Credential profiles must not be combined or redirected.'); diff --git a/agents/policy.ts b/agents/policy.ts index 826c856..80c1df6 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -80,11 +80,14 @@ export function codexBaseArguments(policy: PhasePolicy): readonly string[] { export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCommand { if (!prompt || prompt.includes('\0')) throw new Error('Codex prompt must be nonempty and contain no NUL.'); const sandbox = policy.worktree === 'read-write' ? 'workspace-write' : 'read-only'; - return command(policy, [...codexBaseArguments(policy), 'exec', '--sandbox', sandbox, '--skip-git-repo-check', prompt]); + return command(policy, [...codexBaseArguments(policy), 'exec', '--sandbox', sandbox, '--skip-git-repo-check', + '--output-last-message', '/tmp/codeboost-output/final.txt', prompt]); } export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | 'persist-write' - | 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker'; + | 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker' | 'finite-output' + | 'infinite-stdout' | 'infinite-stderr' | 'infinite-mixed' | 'ignore-term' | 'symlink-output' + | 'oversized-output'; /** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { @@ -107,6 +110,13 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 'must-not-run': 'touch /tmp/command-ran', 'input-marker': 'set -eu; grep -q codeboost-schema-marker /run/codeboost-input/schema.json; ' + 'test ! -e /run/codeboost-input/extra.json', + 'finite-output': 'printf stdout-marker; printf stderr-marker >&2', + 'infinite-stdout': "while :; do head -c 4096 /dev/zero | tr '\\0' x; done", + 'infinite-stderr': "while :; do head -c 4096 /dev/zero | tr '\\0' x >&2; done", + 'infinite-mixed': "while :; do head -c 4096 /dev/zero | tr '\\0' x; head -c 4096 /dev/zero | tr '\\0' y >&2; done", + 'ignore-term': "trap '' TERM; while :; do sleep 1; done", + 'symlink-output': 'ln -s /etc/passwd /tmp/codeboost-output/final.txt', + 'oversized-output': 'head -c 131072 /dev/zero > /tmp/codeboost-output/final.txt', }; return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/test/agent-adapter.test.ts b/test/agent-adapter.test.ts new file mode 100644 index 0000000..eddc772 --- /dev/null +++ b/test/agent-adapter.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { parseClaudeOutput } from '../agents/adapters/claude.ts'; +import { CODEX_OUTPUT_FILE } from '../agents/adapters/codex.ts'; +import { OUTPUT_LIMITS } from '../agents/adapters/supervisor.ts'; +import { captureInvocation } from '../agents/contract.ts'; +import { createCodexCommand, createPhasePolicy } from '../agents/policy.ts'; + +describe('production agent adapters', () => { + it('parses recorded Claude success and failure envelopes', () => { + expect(parseClaudeOutput(Buffer.from('{"result":"planned","is_error":false}'))) + .toEqual({ text: 'planned', providerFailed: false }); + expect(parseClaudeOutput(Buffer.from('{"result":"login required","is_error":true}'))) + .toEqual({ text: 'login required', providerFailed: true }); + expect(() => parseClaudeOutput(Buffer.from('{"result":3,"is_error":false}'))).toThrow('malformed'); + expect(() => parseClaudeOutput(Buffer.from('not json'))).toThrow(); + }); + + it('routes Codex final output to the bounded scratch directory', () => { + const invocation = captureInvocation({ + clone: { id: 'clone', taskId: 'task', directory: '/tmp/task', head: 'a'.repeat(40) }, + phase: 'planning', vendor: 'codex', approvedArgv: [], deadline: 2_000, attemptId: 'adapter-command', + context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, + }, 1_000); + const argv = createCodexCommand(createPhasePolicy(invocation), 'Plan this.').argv; + expect(argv.slice(argv.indexOf('--output-last-message'), argv.indexOf('--output-last-message') + 2)) + .toEqual(['--output-last-message', CODEX_OUTPUT_FILE]); + }); + + it('publishes immutable production output ceilings', () => { + expect(OUTPUT_LIMITS).toEqual({ stdoutBytes: 16 * 1024 * 1024, stderrBytes: 4 * 1024 * 1024, + combinedBytes: 20 * 1024 * 1024 }); + expect(Object.isFrozen(OUTPUT_LIMITS)).toBe(true); + }); +}); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts new file mode 100644 index 0000000..5e87f73 --- /dev/null +++ b/test/agent-supervisor.test.ts @@ -0,0 +1,175 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { startClaudeInvocation } from '../agents/adapters/claude.ts'; +import { readCodexOutput, startCodexInvocation } from '../agents/adapters/codex.ts'; +import { isInvocationActive, startProfileInvocation } from '../agents/adapters/supervisor.ts'; +import { captureInvocation, type InvocationInput } from '../agents/contract.ts'; +import { buildAgentImage } from '../agents/container/image.ts'; +import { createContainerProfile, disposeContainerProfile, type ContainerProfile } from '../agents/container/profile.ts'; +import { prepareTaskFilesystems, removeTaskFilesystems } from '../agents/container/run.ts'; +import { createVendorNetwork } from '../agents/network/network.ts'; +import { createIsolationProbeCommand, createPhasePolicy, type IsolationProbe } from '../agents/policy.ts'; +import { createTaskClone } from '../git/clone.ts'; + +const roots: string[] = [], profiles: ContainerProfile[] = []; +const allocations: ReturnType[] = []; +let imageId = ''; +const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], + { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'agent-supervisor-')); roots.push(root); + const source = join(root, 'source'), staging = join(root, 'staging'), input = join(root, 'input'); + mkdirSync(source); mkdirSync(staging); mkdirSync(input); + git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com'); + writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline'); + writeFileSync(join(input, 'schema.json'), '{}\n'); chmodSync(join(input, 'schema.json'), 0o444); chmodSync(input, 0o555); + const clone = createTaskClone({ source, parent: staging, taskId: 'supervisor', head: git(source, 'rev-parse', 'HEAD') }); + const filesystems = prepareTaskFilesystems(clone, { + workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, + }, imageId); allocations.push(filesystems); + const auth = join(root, 'auth.json'); writeFileSync(auth, '{}', { mode: 0o600 }); + return { root, input, clone, filesystems, auth }; +} +function invocation(data: ReturnType, attemptId: string, deadlineMs = 2 * 60_000, + vendor: 'codex' | 'claude' = 'codex'): InvocationInput { + return captureInvocation({ clone: data.clone, phase: 'planning', vendor, approvedArgv: [], + deadline: Date.now() + deadlineMs, attemptId, + context: { snapshotId: 'snapshot', planId: 'plan', planRevision: 1, assignmentId: 'assignment', + referencedCodeHash: 'code', stateVersion: 1 } }); +} +function profile(data: ReturnType, probe: IsolationProbe, attemptId = `attempt-${Math.random()}`, + deadlineMs = 2 * 60_000, deferredOutput = false) { + const captured = invocation(data, attemptId, deadlineMs), policy = createPhasePolicy(captured); + const network = createVendorNetwork(captured, imageId); + const value = createContainerProfile({ invocation: captured, policy, network, filesystems: data.filesystems, + inputDirectory: data.input, command: createIsolationProbeCommand(policy, probe), imageId, codexAuthFile: data.auth, + deferredOutput }); + profiles.push(value); return value; +} + +beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000); +afterAll(() => { + for (const profile of profiles) disposeContainerProfile(profile); + for (const allocation of allocations.reverse()) removeTaskFilesystems(allocation); + for (const root of roots.reverse()) { + chmodSync(join(root, 'input'), 0o700); + rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } +}, 3 * 60_000); + +describe('container invocation supervisor', () => { + it('captures finite output and releases ownership only after cleanup', async () => { + const current = profile(fixture(), 'finite-output', 'finite'); + const handle = startProfileInvocation(current); + expect(isInvocationActive('finite')).toBe(true); + const result = await handle.settled; + expect(result).toMatchObject({ attemptId: 'finite', exitCode: 0, signal: null, + stdout: 'stdout-marker', stderr: 'stderr-marker' }); + expect(result.stopReason).toBeUndefined(); + expect(isInvocationActive('finite')).toBe(false); + expect(spawnSync('docker', ['container', 'inspect', current.name]).status).not.toBe(0); + expect(spawnSync('docker', ['network', 'inspect', current.network.name]).status).not.toBe(0); + }, 60_000); + + it.each([ + ['infinite-stdout', { stdoutBytes: 64 * 1024, stderrBytes: 32 * 1024, combinedBytes: 96 * 1024 }], + ['infinite-stderr', { stdoutBytes: 64 * 1024, stderrBytes: 32 * 1024, combinedBytes: 96 * 1024 }], + ['infinite-mixed', { stdoutBytes: 64 * 1024, stderrBytes: 64 * 1024, combinedBytes: 48 * 1024 }], + ] as const)('terminates %s at bounded output limits', async (probe, limits) => { + const attemptId = `limit-${probe}`, handle = startProfileInvocation(profile(fixture(), probe, attemptId), { limits }); + const result = await handle.settled; + expect(result.stopReason).toBe('output-limit'); + expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(limits.stdoutBytes); + expect(Buffer.byteLength(result.stderr)).toBeLessThanOrEqual(limits.stderrBytes); + expect(Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr)).toBeLessThanOrEqual(limits.combinedBytes); + expect(isInvocationActive(attemptId)).toBe(false); + }, 60_000); + + it('preserves the first cancellation reason until an ignored SIGTERM fully settles', async () => { + const current = profile(fixture(), 'ignore-term', 'cancelled'); + const handle = startProfileInvocation(current, { timeoutMs: 30_000 }); + let settled = false; void handle.settled.then(() => { settled = true; }); + handle.cancel('cancelled'); handle.cancel('shutdown'); + await Promise.resolve(); + expect(settled).toBe(false); + expect(isInvocationActive('cancelled')).toBe(true); + const result = await handle.settled; + expect(result.stopReason).toBe('cancelled'); + expect(result.stderr).toContain('[codeboost: cancelled]'); + expect(isInvocationActive('cancelled')).toBe(false); + }, 60_000); + + it('enforces a finite wall deadline and force-settles the container', async () => { + const started = Date.now(); + const handle = startProfileInvocation(profile(fixture(), 'ignore-term', 'timeout', 8_000), { timeoutMs: 30_000 }); + const result = await handle.settled; + expect(result.stopReason).toBe('timeout'); + expect(Date.now() - started).toBeLessThan(20_000); + expect(isInvocationActive('timeout')).toBe(false); + }, 60_000); + + it('blocks a duplicate attempt while the original container remains active', async () => { + const data = fixture(), first = startProfileInvocation(profile(data, 'ignore-term', 'duplicate'), { timeoutMs: 30_000 }); + expect(() => startProfileInvocation(profile(data, 'finite-output', 'duplicate'))).toThrow('still active'); + expect(isInvocationActive('duplicate')).toBe(true); + first.cancel('shutdown'); + expect((await first.settled).stopReason).toBe('shutdown'); + }, 60_000); + + it('records decoder failure without publishing a successful result', async () => { + const handle = startProfileInvocation(profile(fixture(), 'finite-output', 'capture-failure'), { + decode: () => { throw new Error('simulated capture failure'); }, + }); + const result = await handle.settled; + expect(result.stopReason).toBe('capture-failure'); + expect(result.stderr).toContain('[codeboost: capture-failure'); + expect(isInvocationActive('capture-failure')).toBe(false); + }, 60_000); + + it.each([ + ['symlink-output', 'capture-failure'], + ['oversized-output', 'output-limit'], + ] as const)('rejects unsafe Codex output from %s', async (probe, reason) => { + const handle = startProfileInvocation(profile(fixture(), probe, `file-${probe}`, 2 * 60_000, true), { + limits: { stdoutBytes: 64 * 1024, stderrBytes: 64 * 1024, combinedBytes: 128 * 1024 }, + decode: (current, _raw, maximum) => readCodexOutput(current.name, maximum), + }); + const result = await handle.settled; + expect(result.stopReason, result.stderr).toBe(reason); + }, 60_000); + + it('rejects limits above the production ceilings and cleans the unused profile', () => { + const current = profile(fixture(), 'finite-output', 'invalid-limit'); + expect(() => startProfileInvocation(current, { limits: { stdoutBytes: 16 * 1024 * 1024 + 1 } })) + .toThrow('production hard limits'); + expect(spawnSync('docker', ['network', 'inspect', current.network.name]).status).not.toBe(0); + }, 60_000); + + if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { + it('runs the production Codex adapter and collects its bounded output file', async () => { + const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; + if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); + const result = await startCodexInvocation({ invocation: invocation(data, 'live-codex', 6 * 60_000), + filesystems: data.filesystems, inputDirectory: data.input, imageId, + prompt: 'Reply only with this exact marker: codeboost-adapter-marker' }, authFile).settled; + expect(result.stopReason).toBeUndefined(); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('codeboost-adapter-marker'); + }, 8 * 60_000); + + it('runs the production Claude adapter and parses its bounded envelope', async () => { + const data = fixture(), token = process.env.CLAUDE_CODE_OAUTH_TOKEN; + if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); + const result = await startClaudeInvocation({ invocation: invocation(data, 'live-claude', 6 * 60_000, 'claude'), + filesystems: data.filesystems, inputDirectory: data.input, imageId, + prompt: 'Reply only with this exact marker: codeboost-adapter-marker' }, token).settled; + expect(result.stopReason).toBeUndefined(); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('codeboost-adapter-marker'); + }, 8 * 60_000); + } +}); From 5dd3a85284471eafe39c4513f1ca4a0f806cb6ec Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 16:53:51 -0700 Subject: [PATCH 20/44] Harden adapter capture settlement --- agents/adapters/codex.ts | 9 +- agents/adapters/supervisor.ts | 152 ++++++++++++++++++++-------------- agents/policy.ts | 4 +- test/agent-supervisor.test.ts | 19 ++++- 4 files changed, 117 insertions(+), 67 deletions(-) diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts index deef695..70436b7 100644 --- a/agents/adapters/codex.ts +++ b/agents/adapters/codex.ts @@ -7,9 +7,10 @@ import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; export const CODEX_OUTPUT_FILE = '/tmp/codeboost-output/final.txt'; -export function readCodexOutput(container: string, maximumBytes: number) { - const output = readBoundedContainerFile(container, CODEX_OUTPUT_FILE, maximumBytes); - return Object.freeze({ text: output.toString('utf8'), additionalBytes: output.length }); +export async function readCodexOutput(container: string, maximumBytes: number, timeoutMs = 30_000) { + const output = await readBoundedContainerFile(container, CODEX_OUTPUT_FILE, maximumBytes, timeoutMs); + const text = new TextDecoder('utf-8', { fatal: true }).decode(output); + return Object.freeze({ text, additionalBytes: output.length }); } export function startCodexInvocation(request: AgentAdapterRequest, @@ -21,7 +22,7 @@ export function startCodexInvocation(request: AgentAdapterRequest, const profile = createContainerProfile({ ...request, policy, network, command: createCodexCommand(policy, request.prompt), codexAuthFile: authFile, deferredOutput: true }); return startProfileInvocation(profile, { ...options, - decode: (current, _raw, maximum) => readCodexOutput(current.name, maximum) }); + decode: (current, _raw, maximum, timeoutMs) => readCodexOutput(current.name, maximum, timeoutMs) }); } catch (error) { removeVendorNetwork(network); throw error; diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index d1d84c3..f78faf5 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { execFile, spawn, type ChildProcess } from 'node:child_process'; import type { InvocationHandle, InvocationResult, StopReason } from '../contract.ts'; import { assertPhasePolicy } from '../policy.ts'; import { createValidatedContainer, disposeValidatedContainer, validateContainer } from '../container/run.ts'; @@ -28,9 +28,11 @@ export interface SupervisorOptions { readonly secrets?: Readonly>; readonly timeoutMs?: number; readonly limits?: Partial; - readonly decode?: (profile: ContainerProfile, rawStdout: Buffer, maximumBytes: number) => DecodedOutput; + readonly decode?: (profile: ContainerProfile, rawStdout: Buffer, maximumBytes: number, + timeoutMs: number) => DecodedOutput | Promise; } export class OutputLimitError extends Error {} +export class CaptureDeadlineError extends Error {} const dockerEnvironment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); const positiveInteger = (value: number, name: string) => { @@ -55,42 +57,42 @@ const withDiagnostic = (stderr: Buffer, stdoutBytes: number, reason: StopReason, if (maximum <= diagnostic.length) return diagnostic.subarray(0, maximum); return Buffer.concat([stderr.subarray(0, maximum - diagnostic.length), diagnostic]); }; -const runControl = (args: readonly string[], timeoutMs = 5_000): ChildProcess => { - const child = spawn('docker', [...args], { env: dockerEnvironment(), stdio: 'ignore' }); - const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); - timer.unref(); - child.once('close', () => clearTimeout(timer)); - child.once('error', () => clearTimeout(timer)); - return child; -}; - /** Read a running container's tmpfs file with a pinned no-follow bounded reader. */ -export function readBoundedContainerFile(container: string, source: string, maximumBytes: number): Buffer { +export function readBoundedContainerFile(container: string, source: string, maximumBytes: number, + timeoutMs = 30_000): Promise { positiveInteger(maximumBytes, 'maximumBytes'); - if (!source.startsWith('/tmp/codeboost-output/') || source.includes('\0')) + positiveInteger(timeoutMs, 'timeoutMs'); + if (!/^\/tmp\/codeboost-output\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source)) throw new Error('Adapter output must come from the bounded output directory.'); - try { - const reader = [ - "const fs=require('node:fs'),path=process.argv[1],maximum=Number(process.argv[2]);", - 'let fd;try{fd=fs.openSync(path,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);', - "const before=fs.fstatSync(fd);if(before.size>maximum)throw new Error('OUTPUT_LIMIT');", - "if(!before.isFile()||before.nlink!==1)throw new Error('UNSAFE_FILE');", - 'const output=Buffer.allocUnsafe(maximum+1);let length=0,count=0;', - 'do{count=fs.readSync(fd,output,length,output.length-length,null);length+=count}', - "while(count>0&&lengthmaximum)throw new Error('OUTPUT_LIMIT');", - 'const after=fs.fstatSync(fd);if(before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size', - "||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)throw new Error('CHANGED_FILE');", - 'process.stdout.write(output.subarray(0,length))}finally{if(fd!==undefined)fs.closeSync(fd)}', - ].join(''); - return execFileSync('docker', ['exec', container, 'node', '-e', reader, source, String(maximumBytes)], { - env: dockerEnvironment(), timeout: 30_000, killSignal: 'SIGKILL', maxBuffer: maximumBytes + 1, - stdio: ['ignore', 'pipe', 'pipe'], + const reader = [ + "const fs=require('node:fs'),path=process.argv[1],maximum=Number(process.argv[2]);", + 'let fd;try{fd=fs.openSync(path,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW|fs.constants.O_NONBLOCK);', + "const before=fs.fstatSync(fd);if(before.size>maximum)throw new Error('OUTPUT_LIMIT');", + "if(!before.isFile()||before.nlink!==1)throw new Error('UNSAFE_FILE');", + 'const output=Buffer.allocUnsafe(maximum+1);let length=0,count=0;', + 'do{count=fs.readSync(fd,output,length,output.length-length,null);length+=count}', + "while(count>0&&lengthmaximum)throw new Error('OUTPUT_LIMIT');", + 'const after=fs.fstatSync(fd);if(before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size', + '||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||after.nlink!==1', + "||!after.isFile())throw new Error('CHANGED_FILE');", + "process.stdout.write(output.subarray(0,length))}catch(error){process.exitCode=error.message==='OUTPUT_LIMIT'?42:43}", + 'finally{if(fd!==undefined)fs.closeSync(fd)}', + ].join(''); + return new Promise((resolve, reject) => { + execFile('docker', ['exec', container, 'node', '-e', reader, source, String(maximumBytes)], { + env: dockerEnvironment(), timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: maximumBytes + 1, + encoding: 'buffer', + }, (error, stdout, stderr) => { + if (!error) { resolve(stdout); return; } + if (error.code === 42) { + reject(new OutputLimitError('Adapter output exceeds its capture limit.')); return; + } + if ('killed' in error && error.killed) { + reject(new CaptureDeadlineError('Adapter output capture exceeded the invocation deadline.')); return; + } + reject(new Error('Adapter output is not a stable bounded unlinked regular file.')); }); - } catch (error) { - const diagnostic = error && typeof error === 'object' && 'stderr' in error ? String(error.stderr) : String(error); - if (diagnostic.includes('OUTPUT_LIMIT')) throw new OutputLimitError('Adapter output exceeds its capture limit.'); - throw new Error('Adapter output is not a stable bounded unlinked regular file.'); - } + }); } export function isInvocationActive(attemptId: string): boolean { @@ -133,12 +135,27 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super const stdoutChunks: Buffer[] = [], stderrChunks: Buffer[] = []; let stdoutBytes = 0, stderrBytes = 0, combinedBytes = 0; let stopReason: StopReason | undefined, failureDetail: string | undefined, closed = false, terminating = false; - let decodedOutput: DecodedOutput | undefined, protocolToken: string | undefined; + let decodedOutput: DecodedOutput | undefined, decodePromise: Promise | undefined; + let protocolToken: string | undefined; let protocolBuffer = Buffer.alloc(0); const child = spawn('docker', ['start', '--attach', profile.name], { env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], }); const timers = new Set>(); + const controls = new Set>(); + const runControl = (args: readonly string[], timeoutMs = 5_000) => { + const operation = new Promise(resolve => { + const control = spawn('docker', [...args], { env: dockerEnvironment(), stdio: 'ignore' }); + const timer = setTimeout(() => control.kill('SIGKILL'), timeoutMs); + timer.unref(); + const done = () => { clearTimeout(timer); resolve(); }; + control.once('close', done); + control.once('error', done); + }); + controls.add(operation); + void operation.finally(() => controls.delete(operation)); + return operation; + }; const later = (callback: () => void, delay: number) => { const timer = setTimeout(() => { timers.delete(timer); callback(); }, delay); timer.unref(); timers.add(timer); return timer; @@ -147,11 +164,11 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super if (terminating || closed) return; terminating = true; child.stdout?.resume(); child.stderr?.resume(); - runControl(['stop', '--signal=TERM', '--time=1', profile.name]); - later(() => { if (!closed) runControl(['kill', '--signal=KILL', profile.name]); }, 1_500); + void runControl(['stop', '--signal=TERM', '--time=1', profile.name]); + later(() => { if (!closed) void runControl(['kill', '--signal=KILL', profile.name]); }, 1_500); later(() => { if (!closed) { - runControl(['rm', '--force', profile.name]); + void runControl(['rm', '--force', profile.name]); child.kill('SIGKILL'); } }, 4_000); @@ -177,24 +194,35 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super if (chunk.length > available) stop('output-limit'); }; const decodeOutput = () => { - if (!options.decode || decodedOutput || stopReason) return; - try { - const raw = Buffer.concat(stdoutChunks, stdoutBytes); - const decoded = options.decode(profile, raw, - Math.max(1, Math.min(limits.stdoutBytes - stdoutBytes, limits.combinedBytes - combinedBytes))); - const additional = decoded.additionalBytes ?? 0; - if (!Number.isSafeInteger(additional) || additional < 0) throw new Error('Adapter returned an invalid byte count.'); - if (stdoutBytes + additional > limits.stdoutBytes || combinedBytes + additional > limits.combinedBytes) - throw new OutputLimitError('Adapter output exceeds its capture limit.'); - decodedOutput = decoded; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const reason = error instanceof OutputLimitError || /exceeds its capture limit/i.test(message) - ? 'output-limit' : 'capture-failure'; - failureDetail ??= message; - if (closed) stopReason ??= reason; - else stop(reason); - } + if (decodePromise) return decodePromise; + if (!options.decode || decodedOutput || stopReason) return Promise.resolve(); + decodePromise = (async () => { + try { + const budget = deadline - Date.now(); + if (budget < 1) throw new CaptureDeadlineError('Invocation deadline expired before output capture.'); + const raw = Buffer.concat(stdoutChunks, stdoutBytes); + const decoded = await options.decode!(profile, raw, + Math.max(1, Math.min(limits.stdoutBytes - stdoutBytes, limits.combinedBytes - combinedBytes)), budget); + const additional = decoded.additionalBytes ?? 0; + const textBytes = Buffer.byteLength(decoded.text); + if (!Number.isSafeInteger(additional) || additional < 0) + throw new Error('Adapter returned an invalid byte count.'); + if ((additional > 0 && additional < textBytes) || textBytes > limits.stdoutBytes + || textBytes + stderrBytes > limits.combinedBytes) + throw new OutputLimitError('Decoded adapter output exceeds its capture limit.'); + if (stdoutBytes + additional > limits.stdoutBytes || combinedBytes + additional > limits.combinedBytes) + throw new OutputLimitError('Adapter output exceeds its capture limit.'); + decodedOutput = decoded; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const reason = error instanceof OutputLimitError || /exceeds its capture limit/i.test(message) + ? 'output-limit' : error instanceof CaptureDeadlineError ? 'timeout' : 'capture-failure'; + failureDetail ??= message; + if (closed) stopReason ??= reason; + else stop(reason); + } + })(); + return decodePromise; }; const protocolLine = (line: Buffer) => { const text = line.toString('utf8').trim(); @@ -206,8 +234,10 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super } const ready = /^\x1eCODEBOOST_READY:([0-9a-f-]{36}):([0-9]+)\x1e$/.exec(text); if (!ready || ready[1] !== protocolToken) return false; - decodeOutput(); - if (decodedOutput) runControl(['exec', profile.name, 'touch', `/tmp/codeboost-output/collected-${ready[1]}`]); + void decodeOutput().then(() => { + if (decodedOutput && !stopReason) + void runControl(['exec', profile.name, 'touch', `/tmp/codeboost-output/collected-${ready[1]}`]); + }); return true; }; const captureStderr = (value: Buffer | string) => { @@ -242,7 +272,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super }); active.set(invocation.attemptId, handle); - child.once('close', (code, signal) => { + child.once('close', async (code, signal) => { for (const timer of timers) clearTimeout(timer); timers.clear(); if (protocolBuffer.length) { @@ -252,7 +282,8 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super closed = true; let finalStdout = Buffer.concat(stdoutChunks, stdoutBytes), finalStderr = Buffer.concat(stderrChunks, stderrBytes); let exitCode = code, finalSignal = signal; - if (!stopReason && code === 0 && options.decode && !profile.deferredOutput) decodeOutput(); + if (!stopReason && code === 0 && options.decode && !profile.deferredOutput) await decodeOutput(); + if (decodePromise) await decodePromise; if (!stopReason && code === 0 && profile.deferredOutput && !decodedOutput) { stopReason = 'capture-failure'; failureDetail ??= 'Deferred output protocol did not complete.'; } @@ -260,6 +291,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super finalStdout = Buffer.from(decodedOutput.text); if (decodedOutput.providerFailed) exitCode = exitCode === 0 ? 1 : exitCode; } + await Promise.all([...controls]); try { disposeValidatedContainer(profile); } catch { diff --git a/agents/policy.ts b/agents/policy.ts index 80c1df6..23e65e5 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -87,7 +87,7 @@ export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCo export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | 'persist-write' | 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker' | 'finite-output' | 'infinite-stdout' | 'infinite-stderr' | 'infinite-mixed' | 'ignore-term' | 'symlink-output' - | 'oversized-output'; + | 'oversized-output' | 'fifo-output' | 'invalid-utf8-output'; /** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { @@ -117,6 +117,8 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 'ignore-term': "trap '' TERM; while :; do sleep 1; done", 'symlink-output': 'ln -s /etc/passwd /tmp/codeboost-output/final.txt', 'oversized-output': 'head -c 131072 /dev/zero > /tmp/codeboost-output/final.txt', + 'fifo-output': 'mkfifo /tmp/codeboost-output/final.txt', + 'invalid-utf8-output': "printf '\\377' > /tmp/codeboost-output/final.txt", }; return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 5e87f73..0e190fb 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { startClaudeInvocation } from '../agents/adapters/claude.ts'; import { readCodexOutput, startCodexInvocation } from '../agents/adapters/codex.ts'; -import { isInvocationActive, startProfileInvocation } from '../agents/adapters/supervisor.ts'; +import { isInvocationActive, readBoundedContainerFile, startProfileInvocation } from '../agents/adapters/supervisor.ts'; import { captureInvocation, type InvocationInput } from '../agents/contract.ts'; import { buildAgentImage } from '../agents/container/image.ts'; import { createContainerProfile, disposeContainerProfile, type ContainerProfile } from '../agents/container/profile.ts'; @@ -130,13 +130,28 @@ describe('container invocation supervisor', () => { expect(isInvocationActive('capture-failure')).toBe(false); }, 60_000); + it('bounds decoded text independently of adapter byte accounting', async () => { + const handle = startProfileInvocation(profile(fixture(), 'finite-output', 'decoded-limit'), { + limits: { stdoutBytes: 64 * 1024, stderrBytes: 64 * 1024, combinedBytes: 128 * 1024 }, + decode: () => ({ text: 'x'.repeat(64 * 1024 + 1), additionalBytes: 0 }), + }); + expect((await handle.settled).stopReason).toBe('output-limit'); + }, 60_000); + + it('rejects traversal before starting an output read', () => { + expect(() => readBoundedContainerFile('unused', + '/tmp/codeboost-output/../../run/codeboost-auth/codex/auth.json', 1024)).toThrow('bounded output directory'); + }); + it.each([ ['symlink-output', 'capture-failure'], ['oversized-output', 'output-limit'], + ['fifo-output', 'capture-failure'], + ['invalid-utf8-output', 'capture-failure'], ] as const)('rejects unsafe Codex output from %s', async (probe, reason) => { const handle = startProfileInvocation(profile(fixture(), probe, `file-${probe}`, 2 * 60_000, true), { limits: { stdoutBytes: 64 * 1024, stderrBytes: 64 * 1024, combinedBytes: 128 * 1024 }, - decode: (current, _raw, maximum) => readCodexOutput(current.name, maximum), + decode: (current, _raw, maximum, timeoutMs) => readCodexOutput(current.name, maximum, timeoutMs), }); const result = await handle.settled; expect(result.stopReason, result.stderr).toBe(reason); From 1ab4dabdcd27733a88701bfbd81a65ad3fe08627 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 17:10:13 -0700 Subject: [PATCH 21/44] Pin adapter output to dedicated tmpfs --- agents/adapters/claude.ts | 3 ++- agents/adapters/codex.ts | 2 +- agents/adapters/supervisor.ts | 27 +++++++++++++++++++-------- agents/container/probe.sh | 3 +-- agents/container/profile.ts | 1 + agents/container/run.ts | 4 +++- agents/policy.ts | 14 ++++++++------ test/agent-adapter.test.ts | 1 + test/agent-supervisor.test.ts | 4 +++- 9 files changed, 39 insertions(+), 20 deletions(-) diff --git a/agents/adapters/claude.ts b/agents/adapters/claude.ts index 41c7cc3..485f2f6 100644 --- a/agents/adapters/claude.ts +++ b/agents/adapters/claude.ts @@ -6,7 +6,8 @@ import { startProfileInvocation } from './supervisor.ts'; import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; export function parseClaudeOutput(raw: Buffer): { text: string; providerFailed: boolean } { - const envelope = JSON.parse(raw.toString('utf8')) as { result?: unknown; is_error?: unknown }; + const envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)) as + { result?: unknown; is_error?: unknown }; if (typeof envelope.result !== 'string' || typeof envelope.is_error !== 'boolean') throw new Error('Claude returned a malformed output envelope.'); return Object.freeze({ text: envelope.result, providerFailed: envelope.is_error }); diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts index 70436b7..1e23d96 100644 --- a/agents/adapters/codex.ts +++ b/agents/adapters/codex.ts @@ -5,7 +5,7 @@ import { createCodexCommand, createPhasePolicy } from '../policy.ts'; import { readBoundedContainerFile, startProfileInvocation } from './supervisor.ts'; import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; -export const CODEX_OUTPUT_FILE = '/tmp/codeboost-output/final.txt'; +export const CODEX_OUTPUT_FILE = '/run/codeboost-output/final.txt'; export async function readCodexOutput(container: string, maximumBytes: number, timeoutMs = 30_000) { const output = await readBoundedContainerFile(container, CODEX_OUTPUT_FILE, maximumBytes, timeoutMs); diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index f78faf5..db5b1be 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -62,7 +62,7 @@ export function readBoundedContainerFile(container: string, source: string, maxi timeoutMs = 30_000): Promise { positiveInteger(maximumBytes, 'maximumBytes'); positiveInteger(timeoutMs, 'timeoutMs'); - if (!/^\/tmp\/codeboost-output\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source)) + if (!/^\/run\/codeboost-output\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source)) throw new Error('Adapter output must come from the bounded output directory.'); const reader = [ "const fs=require('node:fs'),path=process.argv[1],maximum=Number(process.argv[2]);", @@ -142,15 +142,19 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], }); const timers = new Set>(); - const controls = new Set>(); + const controls = new Set>(); const runControl = (args: readonly string[], timeoutMs = 5_000) => { - const operation = new Promise(resolve => { + const operation = new Promise(resolve => { const control = spawn('docker', [...args], { env: dockerEnvironment(), stdio: 'ignore' }); const timer = setTimeout(() => control.kill('SIGKILL'), timeoutMs); timer.unref(); - const done = () => { clearTimeout(timer); resolve(); }; - control.once('close', done); - control.once('error', done); + let completed = false; + const done = (success: boolean) => { + if (completed) return; + completed = true; clearTimeout(timer); resolve(success); + }; + control.once('close', code => done(code === 0)); + control.once('error', () => done(false)); }); controls.add(operation); void operation.finally(() => controls.delete(operation)); @@ -235,8 +239,15 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super const ready = /^\x1eCODEBOOST_READY:([0-9a-f-]{36}):([0-9]+)\x1e$/.exec(text); if (!ready || ready[1] !== protocolToken) return false; void decodeOutput().then(() => { - if (decodedOutput && !stopReason) - void runControl(['exec', profile.name, 'touch', `/tmp/codeboost-output/collected-${ready[1]}`]); + if (decodedOutput && !stopReason) { + void runControl(['exec', profile.name, 'touch', `/run/codeboost-output/collected-${ready[1]}`]) + .then(success => { + if (!success) { + failureDetail ??= 'Deferred output acknowledgement failed.'; + stop('capture-failure'); + } + }); + } }); return true; }; diff --git a/agents/container/probe.sh b/agents/container/probe.sh index 1a6e91f..f7503d4 100644 --- a/agents/container/probe.sh +++ b/agents/container/probe.sh @@ -78,7 +78,6 @@ esac [ "$(codex --version)" = 'codex-cli 0.153.4' ] || fail 'unexpected Codex version' [ "$(claude --version | awk '{print $1}')" = '2.1.281' ] || fail 'unexpected Claude version' -install --directory --owner=10001 --group=10001 --mode=0700 /tmp/codeboost-output if [ "${CODEBOOST_DEFERRED_OUTPUT:-}" = '1' ]; then token="$(cat /proc/sys/kernel/random/uuid)" printf '\036CODEBOOST_START:%s\036\n' "$token" >&2 @@ -87,7 +86,7 @@ if [ "${CODEBOOST_DEFERRED_OUTPUT:-}" = '1' ]; then status="$?" set -e printf '\036CODEBOOST_READY:%s:%s\036\n' "$token" "$status" >&2 - acknowledgement="/tmp/codeboost-output/collected-$token" + acknowledgement="/run/codeboost-output/collected-$token" while [ ! -e "$acknowledgement" ]; do sleep 0.05; done exit "$status" fi diff --git a/agents/container/profile.ts b/agents/container/profile.ts index dead868..af19ee5 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -214,6 +214,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil } if (invocation.vendor === 'codex') { args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', + '--tmpfs', '/run/codeboost-output:rw,nosuid,nodev,noexec,size=20971520,nr_inodes=64,uid=10001,gid=10001,mode=0700', '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); diff --git a/agents/container/run.ts b/agents/container/run.ts index 3bfb5b8..c3c1ce7 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -151,7 +151,9 @@ export function validateContainer(container: string, profile: ContainerProfile, ['/tmp', ['rw', 'nosuid', 'nodev', 'size=33554432', 'nr_inodes=4096', 'mode=1777']], ['/home/codeboost', ['rw', 'nosuid', 'nodev', 'size=1048576', 'nr_inodes=128', 'uid=10001', 'gid=10001', 'mode=0700']], ...(profile.vendor === 'codex' ? [['/run/codeboost-auth/codex', - ['rw', 'nosuid', 'nodev', 'size=4194304', 'nr_inodes=256', 'uid=10001', 'gid=10001', 'mode=0700']] as const] : []), + ['rw', 'nosuid', 'nodev', 'size=4194304', 'nr_inodes=256', 'uid=10001', 'gid=10001', 'mode=0700']] as const, + ['/run/codeboost-output', + ['rw', 'nosuid', 'nodev', 'noexec', 'size=20971520', 'nr_inodes=64', 'uid=10001', 'gid=10001', 'mode=0700']] as const] : []), ]); if (Object.keys(tmpfs).length !== expectedTmpfs.size) throw new Error('Container tmpfs mount set changed.'); for (const [path, expected] of expectedTmpfs) { diff --git a/agents/policy.ts b/agents/policy.ts index 23e65e5..3b179bb 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -81,13 +81,13 @@ export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCo if (!prompt || prompt.includes('\0')) throw new Error('Codex prompt must be nonempty and contain no NUL.'); const sandbox = policy.worktree === 'read-write' ? 'workspace-write' : 'read-only'; return command(policy, [...codexBaseArguments(policy), 'exec', '--sandbox', sandbox, '--skip-git-repo-check', - '--output-last-message', '/tmp/codeboost-output/final.txt', prompt]); + '--output-last-message', '/run/codeboost-output/final.txt', prompt]); } export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | 'persist-write' | 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker' | 'finite-output' | 'infinite-stdout' | 'infinite-stderr' | 'infinite-mixed' | 'ignore-term' | 'symlink-output' - | 'oversized-output' | 'fifo-output' | 'invalid-utf8-output'; + | 'oversized-output' | 'fifo-output' | 'invalid-utf8-output' | 'replace-output-directory' | 'ack-failure'; /** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { @@ -115,10 +115,12 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 'infinite-stderr': "while :; do head -c 4096 /dev/zero | tr '\\0' x >&2; done", 'infinite-mixed': "while :; do head -c 4096 /dev/zero | tr '\\0' x; head -c 4096 /dev/zero | tr '\\0' y >&2; done", 'ignore-term': "trap '' TERM; while :; do sleep 1; done", - 'symlink-output': 'ln -s /etc/passwd /tmp/codeboost-output/final.txt', - 'oversized-output': 'head -c 131072 /dev/zero > /tmp/codeboost-output/final.txt', - 'fifo-output': 'mkfifo /tmp/codeboost-output/final.txt', - 'invalid-utf8-output': "printf '\\377' > /tmp/codeboost-output/final.txt", + 'symlink-output': 'ln -s /etc/passwd /run/codeboost-output/final.txt', + 'oversized-output': 'head -c 131072 /dev/zero > /run/codeboost-output/final.txt', + 'fifo-output': 'mkfifo /run/codeboost-output/final.txt', + 'invalid-utf8-output': "printf '\\377' > /run/codeboost-output/final.txt", + 'replace-output-directory': 'rm -rf /run/codeboost-output; ln -s /etc /run/codeboost-output', + 'ack-failure': "printf captured > /run/codeboost-output/final.txt; chmod 0500 /run/codeboost-output", }; return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/test/agent-adapter.test.ts b/test/agent-adapter.test.ts index eddc772..c79af29 100644 --- a/test/agent-adapter.test.ts +++ b/test/agent-adapter.test.ts @@ -13,6 +13,7 @@ describe('production agent adapters', () => { .toEqual({ text: 'login required', providerFailed: true }); expect(() => parseClaudeOutput(Buffer.from('{"result":3,"is_error":false}'))).toThrow('malformed'); expect(() => parseClaudeOutput(Buffer.from('not json'))).toThrow(); + expect(() => parseClaudeOutput(Buffer.from([0xff]))).toThrow(); }); it('routes Codex final output to the bounded scratch directory', () => { diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 0e190fb..5b594b6 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -140,7 +140,7 @@ describe('container invocation supervisor', () => { it('rejects traversal before starting an output read', () => { expect(() => readBoundedContainerFile('unused', - '/tmp/codeboost-output/../../run/codeboost-auth/codex/auth.json', 1024)).toThrow('bounded output directory'); + '/run/codeboost-output/../../run/codeboost-auth/codex/auth.json', 1024)).toThrow('bounded output directory'); }); it.each([ @@ -148,6 +148,8 @@ describe('container invocation supervisor', () => { ['oversized-output', 'output-limit'], ['fifo-output', 'capture-failure'], ['invalid-utf8-output', 'capture-failure'], + ['replace-output-directory', 'capture-failure'], + ['ack-failure', 'capture-failure'], ] as const)('rejects unsafe Codex output from %s', async (probe, reason) => { const handle = startProfileInvocation(profile(fixture(), probe, `file-${probe}`, 2 * 60_000, true), { limits: { stdoutBytes: 64 * 1024, stderrBytes: 64 * 1024, combinedBytes: 128 * 1024 }, From 4c68faf81ffa3b100b5477adc9dd71215c15f883 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 17:27:49 -0700 Subject: [PATCH 22/44] Close remaining adapter lifecycle races --- agents/adapters/supervisor.ts | 59 +++++++++++++++++++++++++++++------ agents/policy.ts | 4 ++- test/agent-supervisor.test.ts | 25 +++++++++++++++ 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index db5b1be..7f2bc1c 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -50,6 +50,32 @@ const captureLimits = (override: Partial | undefined): CaptureLim }; const diagnosticFor = (reason: StopReason, detail?: string) => Buffer.from( `[codeboost: ${reason}${detail ? `: ${detail.replace(/[\r\n]+/g, ' ').slice(0, 512)}` : ''}]\n`); +const retainCleanupOwnership = (profile: ContainerProfile, detail: string): InvocationHandle => { + const invocation = assertPhasePolicy(profile.policy); + let resolveSettled!: (result: InvocationResult) => void, cleaning = false; + const settled = new Promise(resolve => { resolveSettled = resolve; }); + const retry = () => { + if (cleaning) return; + cleaning = true; + try { + disposeValidatedContainer(profile); + active.delete(invocation.attemptId); + resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, + exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', + stderr: diagnosticFor('capture-failure', detail).toString('utf8') })); + } catch { + const timer = setTimeout(() => { cleaning = false; retry(); }, 1_000); + timer.unref(); + return; + } + cleaning = false; + }; + const handle: InvocationHandle = Object.freeze({ attemptId: invocation.attemptId, settled, + cancel: retry }); + active.set(invocation.attemptId, handle); + const timer = setTimeout(retry, 1_000); timer.unref(); + return handle; +}; const withDiagnostic = (stderr: Buffer, stdoutBytes: number, reason: StopReason, limits: CaptureLimits, detail?: string) => { const diagnostic = diagnosticFor(reason, detail).subarray(0, DIAGNOSTIC_BYTES); @@ -62,21 +88,27 @@ export function readBoundedContainerFile(container: string, source: string, maxi timeoutMs = 30_000): Promise { positiveInteger(maximumBytes, 'maximumBytes'); positiveInteger(timeoutMs, 'timeoutMs'); + if (maximumBytes > OUTPUT_LIMITS.stdoutBytes) + throw new Error('Adapter output read cannot exceed the production stdout limit.'); if (!/^\/run\/codeboost-output\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(source)) throw new Error('Adapter output must come from the bounded output directory.'); const reader = [ - "const fs=require('node:fs'),path=process.argv[1],maximum=Number(process.argv[2]);", - 'let fd;try{fd=fs.openSync(path,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW|fs.constants.O_NONBLOCK);', - "const before=fs.fstatSync(fd);if(before.size>maximum)throw new Error('OUTPUT_LIMIT');", + "const fs=require('node:fs'),path=process.argv[1],maximum=Number(process.argv[2]),directory='/run/codeboost-output';", + 'let dirfd,fd;try{dirfd=fs.openSync(directory,fs.constants.O_RDONLY|fs.constants.O_DIRECTORY|fs.constants.O_NOFOLLOW);', + "fd=fs.openSync('/proc/self/fd/'+dirfd+'/'+path.slice(directory.length+1),", + 'fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW|fs.constants.O_NONBLOCK);', + "const before=fs.fstatSync(fd,{bigint:true});if(before.size>BigInt(maximum))throw new Error('OUTPUT_LIMIT');", "if(!before.isFile()||before.nlink!==1)throw new Error('UNSAFE_FILE');", 'const output=Buffer.allocUnsafe(maximum+1);let length=0,count=0;', 'do{count=fs.readSync(fd,output,length,output.length-length,null);length+=count}', "while(count>0&&lengthmaximum)throw new Error('OUTPUT_LIMIT');", - 'const after=fs.fstatSync(fd);if(before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size', + 'const after=fs.fstatSync(fd,{bigint:true});if(before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size', '||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||after.nlink!==1', "||!after.isFile())throw new Error('CHANGED_FILE');", + "const named=fs.statSync('/proc/self/fd/'+dirfd+'/'+path.slice(directory.length+1),{bigint:true,throwIfNoEntry:false});", + "if(!named||named.dev!==after.dev||named.ino!==after.ino||named.nlink!==1n)throw new Error('REPLACED_FILE');", "process.stdout.write(output.subarray(0,length))}catch(error){process.exitCode=error.message==='OUTPUT_LIMIT'?42:43}", - 'finally{if(fd!==undefined)fs.closeSync(fd)}', + 'finally{if(fd!==undefined)fs.closeSync(fd);if(dirfd!==undefined)fs.closeSync(dirfd)}', ].join(''); return new Promise((resolve, reject) => { execFile('docker', ['exec', container, 'node', '-e', reader, source, String(maximumBytes)], { @@ -128,13 +160,18 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super createValidatedContainer(profile, remaining(), options.secrets ?? {}); validateContainer(profile.name, profile, remaining()); } catch (error) { - try { disposeValidatedContainer(profile); } catch { /* createValidatedContainer already reports unsettled cleanup */ } + try { disposeValidatedContainer(profile); } + catch (cleanupError) { + const detail = `Container validation failed and cleanup remains unsettled: ${String(error)}; ${String(cleanupError)}`; + return retainCleanupOwnership(profile, detail); + } throw error; } const stdoutChunks: Buffer[] = [], stderrChunks: Buffer[] = []; let stdoutBytes = 0, stderrBytes = 0, combinedBytes = 0; - let stopReason: StopReason | undefined, failureDetail: string | undefined, closed = false, terminating = false; + let stopReason: StopReason | undefined, failureDetail: string | undefined; + let closed = false, terminating = false, settlementComplete = false; let decodedOutput: DecodedOutput | undefined, decodePromise: Promise | undefined; let protocolToken: string | undefined; let protocolBuffer = Buffer.alloc(0); @@ -178,9 +215,9 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super }, 4_000); }; const stop = (reason: StopReason) => { - if (closed || stopReason) return; + if (settlementComplete || stopReason) return; stopReason = reason; - terminate(); + if (!closed) terminate(); }; const capture = (stream: 'stdout' | 'stderr', value: Buffer | string) => { if (stopReason || closed) return; @@ -207,6 +244,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super const raw = Buffer.concat(stdoutChunks, stdoutBytes); const decoded = await options.decode!(profile, raw, Math.max(1, Math.min(limits.stdoutBytes - stdoutBytes, limits.combinedBytes - combinedBytes)), budget); + if (stopReason) return; const additional = decoded.additionalBytes ?? 0; const textBytes = Buffer.byteLength(decoded.text); if (!Number.isSafeInteger(additional) || additional < 0) @@ -293,7 +331,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super closed = true; let finalStdout = Buffer.concat(stdoutChunks, stdoutBytes), finalStderr = Buffer.concat(stderrChunks, stderrBytes); let exitCode = code, finalSignal = signal; - if (!stopReason && code === 0 && options.decode && !profile.deferredOutput) await decodeOutput(); + if (!stopReason && options.decode && !profile.deferredOutput) await decodeOutput(); if (decodePromise) await decodePromise; if (!stopReason && code === 0 && profile.deferredOutput && !decodedOutput) { stopReason = 'capture-failure'; failureDetail ??= 'Deferred output protocol did not complete.'; @@ -313,6 +351,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super const result = Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, exitCode, signal: finalSignal, ...(stopReason ? { stopReason } : {}), stdout: finalStdout.toString('utf8'), stderr: finalStderr.toString('utf8') }); + settlementComplete = true; active.delete(invocation.attemptId); resolveSettled(result); }); diff --git a/agents/policy.ts b/agents/policy.ts index 3b179bb..5c39a85 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -87,7 +87,8 @@ export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCo export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | 'persist-write' | 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker' | 'finite-output' | 'infinite-stdout' | 'infinite-stderr' | 'infinite-mixed' | 'ignore-term' | 'symlink-output' - | 'oversized-output' | 'fifo-output' | 'invalid-utf8-output' | 'replace-output-directory' | 'ack-failure'; + | 'oversized-output' | 'fifo-output' | 'invalid-utf8-output' | 'replace-output-directory' | 'ack-failure' + | 'nonzero-output'; /** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { @@ -121,6 +122,7 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 'invalid-utf8-output': "printf '\\377' > /run/codeboost-output/final.txt", 'replace-output-directory': 'rm -rf /run/codeboost-output; ln -s /etc /run/codeboost-output', 'ack-failure': "printf captured > /run/codeboost-output/final.txt; chmod 0500 /run/codeboost-output", + 'nonzero-output': 'printf encoded-output; exit 7', }; return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 5b594b6..0eda4e5 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -130,6 +130,29 @@ describe('container invocation supervisor', () => { expect(isInvocationActive('capture-failure')).toBe(false); }, 60_000); + it('preserves cancellation while post-close decoding is still unsettled', async () => { + let begin!: () => void, release!: () => void; + const started = new Promise(resolve => { begin = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + const handle = startProfileInvocation(profile(fixture(), 'finite-output', 'cancel-during-decode'), { + decode: async () => { begin(); await gate; return { text: 'must-not-publish' }; }, + }); + await started; + handle.cancel('cancelled'); + release(); + const result = await handle.settled; + expect(result.stopReason).toBe('cancelled'); + expect(result.stdout).not.toContain('must-not-publish'); + }, 60_000); + + it('validates and decodes provider output even when the process exits nonzero', async () => { + const result = await startProfileInvocation(profile(fixture(), 'nonzero-output', 'nonzero-decode'), { + decode: (_current, raw) => ({ text: `decoded:${raw.toString('utf8')}` }), + }).settled; + expect(result).toMatchObject({ exitCode: 7, stdout: 'decoded:encoded-output' }); + expect(result.stopReason).toBeUndefined(); + }, 60_000); + it('bounds decoded text independently of adapter byte accounting', async () => { const handle = startProfileInvocation(profile(fixture(), 'finite-output', 'decoded-limit'), { limits: { stdoutBytes: 64 * 1024, stderrBytes: 64 * 1024, combinedBytes: 128 * 1024 }, @@ -141,6 +164,8 @@ describe('container invocation supervisor', () => { it('rejects traversal before starting an output read', () => { expect(() => readBoundedContainerFile('unused', '/run/codeboost-output/../../run/codeboost-auth/codex/auth.json', 1024)).toThrow('bounded output directory'); + expect(() => readBoundedContainerFile('unused', '/run/codeboost-output/final.txt', + 16 * 1024 * 1024 + 1)).toThrow('production stdout limit'); }); it.each([ From 3e57ff750ed9c69286011d9d0075953c45a1ce9d Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 17:39:47 -0700 Subject: [PATCH 23/44] Validate pinned output identities --- agents/adapters/supervisor.ts | 10 ++++++---- test/agent-supervisor.test.ts | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 7f2bc1c..f4f25b4 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -98,16 +98,16 @@ export function readBoundedContainerFile(container: string, source: string, maxi "fd=fs.openSync('/proc/self/fd/'+dirfd+'/'+path.slice(directory.length+1),", 'fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW|fs.constants.O_NONBLOCK);', "const before=fs.fstatSync(fd,{bigint:true});if(before.size>BigInt(maximum))throw new Error('OUTPUT_LIMIT');", - "if(!before.isFile()||before.nlink!==1)throw new Error('UNSAFE_FILE');", + "if(!before.isFile()||before.nlink!==1n)throw new Error('UNSAFE_FILE');", 'const output=Buffer.allocUnsafe(maximum+1);let length=0,count=0;', 'do{count=fs.readSync(fd,output,length,output.length-length,null);length+=count}', "while(count>0&&lengthmaximum)throw new Error('OUTPUT_LIMIT');", 'const after=fs.fstatSync(fd,{bigint:true});if(before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size', - '||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||after.nlink!==1', + '||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||after.nlink!==1n', "||!after.isFile())throw new Error('CHANGED_FILE');", "const named=fs.statSync('/proc/self/fd/'+dirfd+'/'+path.slice(directory.length+1),{bigint:true,throwIfNoEntry:false});", "if(!named||named.dev!==after.dev||named.ino!==after.ino||named.nlink!==1n)throw new Error('REPLACED_FILE');", - "process.stdout.write(output.subarray(0,length))}catch(error){process.exitCode=error.message==='OUTPUT_LIMIT'?42:43}", + "process.stdout.write(output.subarray(0,length))}catch(error){const codes={OUTPUT_LIMIT:42,UNSAFE_FILE:43,CHANGED_FILE:44,REPLACED_FILE:45};process.exitCode=codes[error.message]||46}", 'finally{if(fd!==undefined)fs.closeSync(fd);if(dirfd!==undefined)fs.closeSync(dirfd)}', ].join(''); return new Promise((resolve, reject) => { @@ -122,7 +122,9 @@ export function readBoundedContainerFile(container: string, source: string, maxi if ('killed' in error && error.killed) { reject(new CaptureDeadlineError('Adapter output capture exceeded the invocation deadline.')); return; } - reject(new Error('Adapter output is not a stable bounded unlinked regular file.')); + const reason = error.code === 43 ? 'unsafe type or link count' : error.code === 44 ? 'changed while reading' + : error.code === 45 ? 'pathname identity changed' : 'reader failure'; + reject(new Error(`Adapter output is not a stable bounded unlinked regular file (${reason}).`)); }); }); } diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 0eda4e5..2bd8ed9 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -198,7 +198,7 @@ describe('container invocation supervisor', () => { const result = await startCodexInvocation({ invocation: invocation(data, 'live-codex', 6 * 60_000), filesystems: data.filesystems, inputDirectory: data.input, imageId, prompt: 'Reply only with this exact marker: codeboost-adapter-marker' }, authFile).settled; - expect(result.stopReason).toBeUndefined(); + expect(result.stopReason, result.stderr).toBeUndefined(); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('codeboost-adapter-marker'); }, 8 * 60_000); From 080499c729b68ad0fbbb4b7e26c078eddf4223b0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 17:54:17 -0700 Subject: [PATCH 24/44] Make cleanup and decode settlement retryable --- agents/adapters/supervisor.ts | 52 +++++++++++++++++++++++++++-------- test/agent-supervisor.test.ts | 11 ++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index f4f25b4..e9f8c99 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -53,27 +53,35 @@ const diagnosticFor = (reason: StopReason, detail?: string) => Buffer.from( const retainCleanupOwnership = (profile: ContainerProfile, detail: string): InvocationHandle => { const invocation = assertPhasePolicy(profile.policy); let resolveSettled!: (result: InvocationResult) => void, cleaning = false; + let timer: ReturnType | undefined; const settled = new Promise(resolve => { resolveSettled = resolve; }); + const schedule = () => { + if (timer) return; + timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); + timer.unref(); + }; const retry = () => { if (cleaning) return; cleaning = true; try { disposeValidatedContainer(profile); + if (timer) clearTimeout(timer); + timer = undefined; active.delete(invocation.attemptId); resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', stderr: diagnosticFor('capture-failure', detail).toString('utf8') })); } catch { - const timer = setTimeout(() => { cleaning = false; retry(); }, 1_000); - timer.unref(); + cleaning = false; + schedule(); return; } cleaning = false; }; const handle: InvocationHandle = Object.freeze({ attemptId: invocation.attemptId, settled, - cancel: retry }); + cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); active.set(invocation.attemptId, handle); - const timer = setTimeout(retry, 1_000); timer.unref(); + schedule(); return handle; }; const withDiagnostic = (stderr: Buffer, stdoutBytes: number, reason: StopReason, limits: CaptureLimits, @@ -244,8 +252,17 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super const budget = deadline - Date.now(); if (budget < 1) throw new CaptureDeadlineError('Invocation deadline expired before output capture.'); const raw = Buffer.concat(stdoutChunks, stdoutBytes); - const decoded = await options.decode!(profile, raw, - Math.max(1, Math.min(limits.stdoutBytes - stdoutBytes, limits.combinedBytes - combinedBytes)), budget); + const operation = Promise.resolve(options.decode!(profile, raw, + Math.max(1, Math.min(limits.stdoutBytes - stdoutBytes, limits.combinedBytes - combinedBytes)), budget)); + let decodeTimer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + decodeTimer = setTimeout(() => reject( + new CaptureDeadlineError('Adapter output capture exceeded the invocation deadline.')), budget); + decodeTimer.unref(); + }); + let decoded: DecodedOutput; + try { decoded = await Promise.race([operation, timeout]); } + finally { if (decodeTimer) clearTimeout(decodeTimer); } if (stopReason) return; const additional = decoded.additionalBytes ?? 0; const textBytes = Buffer.byteLength(decoded.text); @@ -315,11 +332,12 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super later(() => stop('timeout'), Math.max(1, deadline - Date.now())); let resolveSettled!: (result: InvocationResult) => void; + let wakeCleanup: (() => void) | undefined; const settled = new Promise(resolve => { resolveSettled = resolve; }); const handle: InvocationHandle = Object.freeze({ attemptId: invocation.attemptId, settled, - cancel: (reason: StopReason) => stop(reason), + cancel: (reason: StopReason) => { stop(reason); wakeCleanup?.(); }, }); active.set(invocation.attemptId, handle); @@ -343,11 +361,21 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super if (decodedOutput.providerFailed) exitCode = exitCode === 0 ? 1 : exitCode; } await Promise.all([...controls]); - try { - disposeValidatedContainer(profile); - } catch { - stopReason ??= 'capture-failure'; - return; // Ownership remains active because termination/cleanup was not confirmed. + while (true) { + try { + disposeValidatedContainer(profile); + wakeCleanup = undefined; + break; + } catch (error) { + stopReason ??= 'capture-failure'; + failureDetail ??= error instanceof Error ? error.message : String(error); + await new Promise(resolve => { + let finished = false; + const wake = () => { if (finished) return; finished = true; clearTimeout(timer); resolve(); }; + const timer = setTimeout(wake, 1_000); timer.unref(); + wakeCleanup = wake; + }); + } } if (stopReason) finalStderr = withDiagnostic(finalStderr, finalStdout.length, stopReason, limits, failureDetail); const result = Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 2bd8ed9..87cdcf9 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -145,6 +145,17 @@ describe('container invocation supervisor', () => { expect(result.stdout).not.toContain('must-not-publish'); }, 60_000); + it('keeps post-close decoding inside the invocation deadline', async () => { + const started = Date.now(); + const result = await startProfileInvocation(profile(fixture(), 'finite-output', 'decode-timeout', 30_000), { + timeoutMs: 3_000, + decode: () => new Promise(() => {}), + }).settled; + expect(result.stopReason).toBe('timeout'); + expect(Date.now() - started).toBeLessThan(10_000); + expect(isInvocationActive('decode-timeout')).toBe(false); + }, 30_000); + it('validates and decodes provider output even when the process exits nonzero', async () => { const result = await startProfileInvocation(profile(fixture(), 'nonzero-output', 'nonzero-decode'), { decode: (_current, raw) => ({ text: `decoded:${raw.toString('utf8')}` }), From 58a0d429bd6d2c793402df253143f20931e487bb Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 18:11:55 -0700 Subject: [PATCH 25/44] Abort and await bounded adapter capture --- agents/adapters/codex.ts | 8 +++--- agents/adapters/supervisor.ts | 50 +++++++++++++++++++++++++---------- test/agent-supervisor.test.ts | 18 ++++++++++++- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts index 1e23d96..f0e4135 100644 --- a/agents/adapters/codex.ts +++ b/agents/adapters/codex.ts @@ -7,8 +7,9 @@ import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; export const CODEX_OUTPUT_FILE = '/run/codeboost-output/final.txt'; -export async function readCodexOutput(container: string, maximumBytes: number, timeoutMs = 30_000) { - const output = await readBoundedContainerFile(container, CODEX_OUTPUT_FILE, maximumBytes, timeoutMs); +export async function readCodexOutput(container: string, maximumBytes: number, timeoutMs = 30_000, + signal?: AbortSignal) { + const output = await readBoundedContainerFile(container, CODEX_OUTPUT_FILE, maximumBytes, timeoutMs, signal); const text = new TextDecoder('utf-8', { fatal: true }).decode(output); return Object.freeze({ text, additionalBytes: output.length }); } @@ -22,7 +23,8 @@ export function startCodexInvocation(request: AgentAdapterRequest, const profile = createContainerProfile({ ...request, policy, network, command: createCodexCommand(policy, request.prompt), codexAuthFile: authFile, deferredOutput: true }); return startProfileInvocation(profile, { ...options, - decode: (current, _raw, maximum, timeoutMs) => readCodexOutput(current.name, maximum, timeoutMs) }); + decode: (current, _raw, maximum, timeoutMs, signal) => + readCodexOutput(current.name, maximum, timeoutMs, signal) }); } catch (error) { removeVendorNetwork(network); throw error; diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index e9f8c99..6a0dbc3 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -29,7 +29,7 @@ export interface SupervisorOptions { readonly timeoutMs?: number; readonly limits?: Partial; readonly decode?: (profile: ContainerProfile, rawStdout: Buffer, maximumBytes: number, - timeoutMs: number) => DecodedOutput | Promise; + timeoutMs: number, signal: AbortSignal) => DecodedOutput | Promise; } export class OutputLimitError extends Error {} export class CaptureDeadlineError extends Error {} @@ -93,7 +93,7 @@ const withDiagnostic = (stderr: Buffer, stdoutBytes: number, reason: StopReason, }; /** Read a running container's tmpfs file with a pinned no-follow bounded reader. */ export function readBoundedContainerFile(container: string, source: string, maximumBytes: number, - timeoutMs = 30_000): Promise { + timeoutMs = 30_000, signal?: AbortSignal): Promise { positiveInteger(maximumBytes, 'maximumBytes'); positiveInteger(timeoutMs, 'timeoutMs'); if (maximumBytes > OUTPUT_LIMITS.stdoutBytes) @@ -113,15 +113,15 @@ export function readBoundedContainerFile(container: string, source: string, maxi 'const after=fs.fstatSync(fd,{bigint:true});if(before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size', '||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||after.nlink!==1n', "||!after.isFile())throw new Error('CHANGED_FILE');", - "const named=fs.statSync('/proc/self/fd/'+dirfd+'/'+path.slice(directory.length+1),{bigint:true,throwIfNoEntry:false});", - "if(!named||named.dev!==after.dev||named.ino!==after.ino||named.nlink!==1n)throw new Error('REPLACED_FILE');", + "const named=fs.lstatSync('/proc/self/fd/'+dirfd+'/'+path.slice(directory.length+1),{bigint:true,throwIfNoEntry:false});", + "if(!named||!named.isFile()||named.dev!==after.dev||named.ino!==after.ino||named.nlink!==1n)throw new Error('REPLACED_FILE');", "process.stdout.write(output.subarray(0,length))}catch(error){const codes={OUTPUT_LIMIT:42,UNSAFE_FILE:43,CHANGED_FILE:44,REPLACED_FILE:45};process.exitCode=codes[error.message]||46}", 'finally{if(fd!==undefined)fs.closeSync(fd);if(dirfd!==undefined)fs.closeSync(dirfd)}', ].join(''); return new Promise((resolve, reject) => { execFile('docker', ['exec', container, 'node', '-e', reader, source, String(maximumBytes)], { env: dockerEnvironment(), timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: maximumBytes + 1, - encoding: 'buffer', + encoding: 'buffer', signal, }, (error, stdout, stderr) => { if (!error) { resolve(stdout); return; } if (error.code === 42) { @@ -143,6 +143,14 @@ export function isInvocationActive(attemptId: string): boolean { export function startProfileInvocation(profile: ContainerProfile, options: SupervisorOptions = {}): InvocationHandle { const invocation = assertPhasePolicy(profile.policy); + const rejectWithCleanup = (error: unknown): InvocationHandle => { + try { disposeValidatedContainer(profile); } + catch (cleanupError) { + return retainCleanupOwnership(profile, + `Invocation was rejected and cleanup remains unsettled: ${String(error)}; ${String(cleanupError)}`); + } + throw error; + }; if (active.has(invocation.attemptId)) { disposeValidatedContainer(profile); throw new Error('An invocation with this attempt ID is still active.'); @@ -152,14 +160,14 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super limits = captureLimits(options.limits); configuredTimeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; positiveInteger(configuredTimeout, 'timeoutMs'); + if (configuredTimeout > DEFAULT_TIMEOUT_MS) + throw new Error('timeoutMs cannot exceed the production ten-minute ceiling.'); } catch (error) { - disposeValidatedContainer(profile); - throw error; + return rejectWithCleanup(error); } const now = Date.now(), deadline = Math.min(invocation.deadline, now + configuredTimeout); if (!Number.isSafeInteger(deadline) || deadline <= now) { - disposeValidatedContainer(profile); - throw new Error('Invocation deadline has already expired.'); + return rejectWithCleanup(new Error('Invocation deadline has already expired.')); } const remaining = () => { const value = deadline - Date.now(); @@ -183,6 +191,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super let stopReason: StopReason | undefined, failureDetail: string | undefined; let closed = false, terminating = false, settlementComplete = false; let decodedOutput: DecodedOutput | undefined, decodePromise: Promise | undefined; + let decodeAbort: AbortController | undefined; let protocolToken: string | undefined; let protocolBuffer = Buffer.alloc(0); const child = spawn('docker', ['start', '--attach', profile.name], { @@ -227,6 +236,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super const stop = (reason: StopReason) => { if (settlementComplete || stopReason) return; stopReason = reason; + decodeAbort?.abort(); if (!closed) terminate(); }; const capture = (stream: 'stdout' | 'stderr', value: Buffer | string) => { @@ -251,18 +261,30 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super try { const budget = deadline - Date.now(); if (budget < 1) throw new CaptureDeadlineError('Invocation deadline expired before output capture.'); + const controller = new AbortController(); + decodeAbort = controller; const raw = Buffer.concat(stdoutChunks, stdoutBytes); const operation = Promise.resolve(options.decode!(profile, raw, - Math.max(1, Math.min(limits.stdoutBytes - stdoutBytes, limits.combinedBytes - combinedBytes)), budget)); + Math.max(1, Math.min(limits.stdoutBytes - stdoutBytes, limits.combinedBytes - combinedBytes)), + budget, controller.signal)); let decodeTimer: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { - decodeTimer = setTimeout(() => reject( - new CaptureDeadlineError('Adapter output capture exceeded the invocation deadline.')), budget); + decodeTimer = setTimeout(() => { + stop('timeout'); + reject(new CaptureDeadlineError('Adapter output capture exceeded the invocation deadline.')); + }, budget); decodeTimer.unref(); }); let decoded: DecodedOutput; try { decoded = await Promise.race([operation, timeout]); } - finally { if (decodeTimer) clearTimeout(decodeTimer); } + catch (error) { + controller.abort(); + try { await operation; } catch { /* termination is confirmed by operation settlement */ } + throw error; + } finally { + if (decodeTimer) clearTimeout(decodeTimer); + if (decodeAbort === controller) decodeAbort = undefined; + } if (stopReason) return; const additional = decoded.additionalBytes ?? 0; const textBytes = Buffer.byteLength(decoded.text); @@ -353,7 +375,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super let exitCode = code, finalSignal = signal; if (!stopReason && options.decode && !profile.deferredOutput) await decodeOutput(); if (decodePromise) await decodePromise; - if (!stopReason && code === 0 && profile.deferredOutput && !decodedOutput) { + if (!stopReason && profile.deferredOutput && !decodedOutput) { stopReason = 'capture-failure'; failureDetail ??= 'Deferred output protocol did not complete.'; } if (decodedOutput) { diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 87cdcf9..aa84752 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -149,7 +149,8 @@ describe('container invocation supervisor', () => { const started = Date.now(); const result = await startProfileInvocation(profile(fixture(), 'finite-output', 'decode-timeout', 30_000), { timeoutMs: 3_000, - decode: () => new Promise(() => {}), + decode: (_current, _raw, _maximum, _timeout, signal) => new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(new Error('decoder aborted')), { once: true })), }).settled; expect(result.stopReason).toBe('timeout'); expect(Date.now() - started).toBeLessThan(10_000); @@ -202,6 +203,21 @@ describe('container invocation supervisor', () => { expect(spawnSync('docker', ['network', 'inspect', current.network.name]).status).not.toBe(0); }, 60_000); + it('rejects timeouts above the production ceiling and cleans the unused profile', () => { + const current = profile(fixture(), 'finite-output', 'invalid-timeout'); + expect(() => startProfileInvocation(current, { timeoutMs: 10 * 60_000 + 1 })) + .toThrow('ten-minute ceiling'); + expect(spawnSync('docker', ['network', 'inspect', current.network.name]).status).not.toBe(0); + }, 60_000); + + it('fails closed when deferred output is never produced', async () => { + const handle = startProfileInvocation(profile(fixture(), 'nonzero-output', 'missing-deferred', 2 * 60_000, true), { + decode: (current, _raw, maximum, timeoutMs, signal) => + readCodexOutput(current.name, maximum, timeoutMs, signal), + }); + expect((await handle.settled).stopReason).toBe('capture-failure'); + }, 60_000); + if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { it('runs the production Codex adapter and collects its bounded output file', async () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; From 38b020d0ee0aa054599cf976ff8b5f247e69c4c2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 18:32:20 -0700 Subject: [PATCH 26/44] Retain adapter cleanup ownership --- agents/adapters/claude.ts | 5 ++-- agents/adapters/codex.ts | 5 ++-- agents/adapters/supervisor.ts | 49 +++++++++++++++++++++++++++++++++-- test/agent-supervisor.test.ts | 11 ++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/agents/adapters/claude.ts b/agents/adapters/claude.ts index 485f2f6..6b4c48e 100644 --- a/agents/adapters/claude.ts +++ b/agents/adapters/claude.ts @@ -2,7 +2,7 @@ import type { InvocationHandle } from '../contract.ts'; import { createContainerProfile } from '../container/profile.ts'; import { createVendorNetwork, removeVendorNetwork } from '../network/network.ts'; import { createClaudeCommand, createPhasePolicy } from '../policy.ts'; -import { startProfileInvocation } from './supervisor.ts'; +import { retainNetworkCleanup, startProfileInvocation } from './supervisor.ts'; import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; export function parseClaudeOutput(raw: Buffer): { text: string; providerFailed: boolean } { @@ -24,7 +24,8 @@ export function startClaudeInvocation(request: AgentAdapterRequest, return startProfileInvocation(profile, { ...options, secrets: { CLAUDE_CODE_OAUTH_TOKEN: oauthToken }, decode: (_profile, raw) => parseClaudeOutput(raw) }); } catch (error) { - removeVendorNetwork(network); + try { removeVendorNetwork(network); } + catch (cleanupError) { return retainNetworkCleanup(request.invocation, network, error, cleanupError); } throw error; } } diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts index f0e4135..777f9ef 100644 --- a/agents/adapters/codex.ts +++ b/agents/adapters/codex.ts @@ -2,7 +2,7 @@ import type { InvocationHandle } from '../contract.ts'; import { createContainerProfile } from '../container/profile.ts'; import { createVendorNetwork, removeVendorNetwork } from '../network/network.ts'; import { createCodexCommand, createPhasePolicy } from '../policy.ts'; -import { readBoundedContainerFile, startProfileInvocation } from './supervisor.ts'; +import { readBoundedContainerFile, retainNetworkCleanup, startProfileInvocation } from './supervisor.ts'; import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; export const CODEX_OUTPUT_FILE = '/run/codeboost-output/final.txt'; @@ -26,7 +26,8 @@ export function startCodexInvocation(request: AgentAdapterRequest, decode: (current, _raw, maximum, timeoutMs, signal) => readCodexOutput(current.name, maximum, timeoutMs, signal) }); } catch (error) { - removeVendorNetwork(network); + try { removeVendorNetwork(network); } + catch (cleanupError) { return retainNetworkCleanup(request.invocation, network, error, cleanupError); } throw error; } } diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 6a0dbc3..eec900f 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -1,8 +1,9 @@ import { execFile, spawn, type ChildProcess } from 'node:child_process'; -import type { InvocationHandle, InvocationResult, StopReason } from '../contract.ts'; +import type { InvocationHandle, InvocationInput, InvocationResult, StopReason } from '../contract.ts'; import { assertPhasePolicy } from '../policy.ts'; import { createValidatedContainer, disposeValidatedContainer, validateContainer } from '../container/run.ts'; import type { ContainerProfile } from '../container/profile.ts'; +import { removeVendorNetwork, type VendorNetwork } from '../network/network.ts'; export const OUTPUT_LIMITS = Object.freeze({ stdoutBytes: 16 * 1024 * 1024, @@ -10,6 +11,7 @@ export const OUTPUT_LIMITS = Object.freeze({ combinedBytes: 20 * 1024 * 1024, }); const DEFAULT_TIMEOUT_MS = 10 * 60_000; +const CAPTURE_ABORT_GRACE_MS = 1_000; const DIAGNOSTIC_BYTES = 1024; const active = new Map(); @@ -84,6 +86,42 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string): Invo schedule(); return handle; }; + +/** Retain attempt ownership while retrying a network allocated before profile construction failed. */ +export function retainNetworkCleanup(invocation: InvocationInput, network: VendorNetwork, + startupError: unknown, cleanupError: unknown): InvocationHandle { + if (active.has(invocation.attemptId)) throw cleanupError; + let resolveSettled!: (result: InvocationResult) => void, cleaning = false; + let timer: ReturnType | undefined; + const settled = new Promise(resolve => { resolveSettled = resolve; }); + const detail = `Adapter startup failed and network cleanup remains unsettled: ${String(startupError)}; ${String(cleanupError)}`; + const retry = () => { + if (cleaning) return; + cleaning = true; + try { + removeVendorNetwork(network); + if (timer) clearTimeout(timer); + timer = undefined; + active.delete(invocation.attemptId); + resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, + exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', + stderr: diagnosticFor('capture-failure', detail).toString('utf8') })); + } catch { + cleaning = false; + if (!timer) { + timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); + timer.unref(); + } + return; + } + cleaning = false; + }; + const handle: InvocationHandle = Object.freeze({ attemptId: invocation.attemptId, settled, + cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); + active.set(invocation.attemptId, handle); + timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); timer.unref(); + return handle; +} const withDiagnostic = (stderr: Buffer, stdoutBytes: number, reason: StopReason, limits: CaptureLimits, detail?: string) => { const diagnostic = diagnosticFor(reason, detail).subarray(0, DIAGNOSTIC_BYTES); @@ -279,7 +317,14 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super try { decoded = await Promise.race([operation, timeout]); } catch (error) { controller.abort(); - try { await operation; } catch { /* termination is confirmed by operation settlement */ } + let graceTimer: ReturnType | undefined; + const grace = new Promise(resolve => { + graceTimer = setTimeout(resolve, CAPTURE_ABORT_GRACE_MS); + graceTimer.unref(); + }); + await Promise.race([operation.then(() => undefined, () => undefined), grace]); + if (graceTimer) clearTimeout(graceTimer); + void operation.catch(() => { /* prevent a detached noncooperative decoder from becoming unhandled */ }); throw error; } finally { if (decodeTimer) clearTimeout(decodeTimer); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index aa84752..25d172c 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -157,6 +157,17 @@ describe('container invocation supervisor', () => { expect(isInvocationActive('decode-timeout')).toBe(false); }, 30_000); + it('does not wedge when an injected decoder ignores abort', async () => { + const started = Date.now(); + const result = await startProfileInvocation(profile(fixture(), 'finite-output', 'decode-ignores-abort', 30_000), { + timeoutMs: 3_000, + decode: () => new Promise(() => {}), + }).settled; + expect(result.stopReason).toBe('timeout'); + expect(Date.now() - started).toBeLessThan(10_000); + expect(isInvocationActive('decode-ignores-abort')).toBe(false); + }, 30_000); + it('validates and decodes provider output even when the process exits nonzero', async () => { const result = await startProfileInvocation(profile(fixture(), 'nonzero-output', 'nonzero-decode'), { decode: (_current, raw) => ({ text: `decoded:${raw.toString('utf8')}` }), From 9a8ff6ab4da21861b04995e1f6e0aca10255cfae Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 18:45:14 -0700 Subject: [PATCH 27/44] Allow loaded Docker cleanup observation --- test/agent-supervisor.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 25d172c..492efd5 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -153,7 +153,7 @@ describe('container invocation supervisor', () => { signal.addEventListener('abort', () => reject(new Error('decoder aborted')), { once: true })), }).settled; expect(result.stopReason).toBe('timeout'); - expect(Date.now() - started).toBeLessThan(10_000); + expect(Date.now() - started).toBeLessThan(15_000); expect(isInvocationActive('decode-timeout')).toBe(false); }, 30_000); @@ -164,7 +164,7 @@ describe('container invocation supervisor', () => { decode: () => new Promise(() => {}), }).settled; expect(result.stopReason).toBe('timeout'); - expect(Date.now() - started).toBeLessThan(10_000); + expect(Date.now() - started).toBeLessThan(15_000); expect(isInvocationActive('decode-ignores-abort')).toBe(false); }, 30_000); From 7c4e0986b55b8cd4ea48bf956198d6b7215833e1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 19:07:51 -0700 Subject: [PATCH 28/44] Secure deferred output acknowledgement --- agents/adapters/supervisor.ts | 75 +++++++++++++++++++++++++++-------- agents/container/probe.sh | 20 +++++++++- agents/container/profile.ts | 3 +- agents/container/run.ts | 2 + agents/policy.ts | 6 +-- test/agent-supervisor.test.ts | 15 ++++++- 6 files changed, 97 insertions(+), 24 deletions(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index eec900f..46059a3 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -52,9 +52,10 @@ const captureLimits = (override: Partial | undefined): CaptureLim }; const diagnosticFor = (reason: StopReason, detail?: string) => Buffer.from( `[codeboost: ${reason}${detail ? `: ${detail.replace(/[\r\n]+/g, ' ').slice(0, 512)}` : ''}]\n`); -const retainCleanupOwnership = (profile: ContainerProfile, detail: string): InvocationHandle => { +const retainCleanupOwnership = (profile: ContainerProfile, detail: string, register = true): InvocationHandle => { const invocation = assertPhasePolicy(profile.policy); - let resolveSettled!: (result: InvocationResult) => void, cleaning = false; + let resolveSettled!: (result: InvocationResult) => void, cleaning = false, complete = false; + let handle!: InvocationHandle; let timer: ReturnType | undefined; const settled = new Promise(resolve => { resolveSettled = resolve; }); const schedule = () => { @@ -63,13 +64,14 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string): Invo timer.unref(); }; const retry = () => { - if (cleaning) return; + if (cleaning || complete) return; cleaning = true; try { disposeValidatedContainer(profile); if (timer) clearTimeout(timer); timer = undefined; - active.delete(invocation.attemptId); + complete = true; + if (active.get(invocation.attemptId) === handle) active.delete(invocation.attemptId); resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', stderr: diagnosticFor('capture-failure', detail).toString('utf8') })); @@ -80,9 +82,9 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string): Invo } cleaning = false; }; - const handle: InvocationHandle = Object.freeze({ attemptId: invocation.attemptId, settled, + handle = Object.freeze({ attemptId: invocation.attemptId, settled, cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); - active.set(invocation.attemptId, handle); + if (register) active.set(invocation.attemptId, handle); schedule(); return handle; }; @@ -91,18 +93,20 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string): Invo export function retainNetworkCleanup(invocation: InvocationInput, network: VendorNetwork, startupError: unknown, cleanupError: unknown): InvocationHandle { if (active.has(invocation.attemptId)) throw cleanupError; - let resolveSettled!: (result: InvocationResult) => void, cleaning = false; + let resolveSettled!: (result: InvocationResult) => void, cleaning = false, complete = false; + let handle!: InvocationHandle; let timer: ReturnType | undefined; const settled = new Promise(resolve => { resolveSettled = resolve; }); const detail = `Adapter startup failed and network cleanup remains unsettled: ${String(startupError)}; ${String(cleanupError)}`; const retry = () => { - if (cleaning) return; + if (cleaning || complete) return; cleaning = true; try { removeVendorNetwork(network); if (timer) clearTimeout(timer); timer = undefined; - active.delete(invocation.attemptId); + complete = true; + if (active.get(invocation.attemptId) === handle) active.delete(invocation.attemptId); resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', stderr: diagnosticFor('capture-failure', detail).toString('utf8') })); @@ -116,7 +120,7 @@ export function retainNetworkCleanup(invocation: InvocationInput, network: Vendo } cleaning = false; }; - const handle: InvocationHandle = Object.freeze({ attemptId: invocation.attemptId, settled, + handle = Object.freeze({ attemptId: invocation.attemptId, settled, cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); active.set(invocation.attemptId, handle); timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); timer.unref(); @@ -181,17 +185,16 @@ export function isInvocationActive(attemptId: string): boolean { export function startProfileInvocation(profile: ContainerProfile, options: SupervisorOptions = {}): InvocationHandle { const invocation = assertPhasePolicy(profile.policy); - const rejectWithCleanup = (error: unknown): InvocationHandle => { + const rejectWithCleanup = (error: unknown, register = true): InvocationHandle => { try { disposeValidatedContainer(profile); } catch (cleanupError) { return retainCleanupOwnership(profile, - `Invocation was rejected and cleanup remains unsettled: ${String(error)}; ${String(cleanupError)}`); + `Invocation was rejected and cleanup remains unsettled: ${String(error)}; ${String(cleanupError)}`, register); } throw error; }; if (active.has(invocation.attemptId)) { - disposeValidatedContainer(profile); - throw new Error('An invocation with this attempt ID is still active.'); + return rejectWithCleanup(new Error('An invocation with this attempt ID is still active.'), false); } let limits: CaptureLimits, configuredTimeout: number; try { @@ -230,7 +233,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super let closed = false, terminating = false, settlementComplete = false; let decodedOutput: DecodedOutput | undefined, decodePromise: Promise | undefined; let decodeAbort: AbortController | undefined; - let protocolToken: string | undefined; + let protocolToken: string | undefined, protocolStarted = false, protocolReady = false; let protocolBuffer = Buffer.alloc(0); const child = spawn('docker', ['start', '--attach', profile.name], { env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], @@ -254,6 +257,18 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super void operation.finally(() => controls.delete(operation)); return operation; }; + const acknowledgeDeferredOutput = (token: string) => { + const script = [ + "const fs=require('node:fs'),token=process.argv[1],directory='/run/codeboost-control';", + 'let dirfd,fd;try{dirfd=fs.openSync(directory,fs.constants.O_RDONLY|fs.constants.O_DIRECTORY|fs.constants.O_NOFOLLOW);', + "fd=fs.openSync('/proc/self/fd/'+dirfd+'/collected-'+token,", + 'fs.constants.O_WRONLY|fs.constants.O_CREAT|fs.constants.O_EXCL|fs.constants.O_NOFOLLOW,0o444);', + "fs.writeFileSync(fd,token);fs.fsyncSync(fd);const stat=fs.fstatSync(fd,{bigint:true});", + "if(!stat.isFile()||stat.nlink!==1n)throw new Error('UNSAFE_ACK')}finally{if(fd!==undefined)fs.closeSync(fd);", + 'if(dirfd!==undefined)fs.closeSync(dirfd)}', + ].join(''); + return runControl(['exec', '--user', '0', profile.name, 'node', '-e', script, token]); + }; const later = (callback: () => void, delay: number) => { const timer = setTimeout(() => { timers.delete(timer); callback(); }, delay); timer.unref(); timers.add(timer); return timer; @@ -292,6 +307,14 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super } if (chunk.length > available) stop('output-limit'); }; + const consumeProtocol = (length: number) => { + const available = Math.max(0, Math.min(limits.stderrBytes - stderrBytes, + limits.combinedBytes - combinedBytes)); + const consumed = Math.min(length, available); + stderrBytes += consumed; + combinedBytes += consumed; + if (length > available) stop('output-limit'); + }; const decodeOutput = () => { if (decodePromise) return decodePromise; if (!options.decode || decodedOutput || stopReason) return Promise.resolve(); @@ -356,15 +379,32 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super const text = line.toString('utf8').trim(); const started = /^\x1eCODEBOOST_START:([0-9a-f-]{36})\x1e$/.exec(text); if (started) { + consumeProtocol(line.length); + if (stopReason) return true; + if (protocolStarted) { + failureDetail ??= 'Deferred output emitted a duplicate START frame.'; + stop('capture-failure'); + return true; + } if (protocolToken && protocolToken !== started[1]) return false; + protocolStarted = true; protocolToken = started[1]; return true; } const ready = /^\x1eCODEBOOST_READY:([0-9a-f-]{36}):([0-9]+)\x1e$/.exec(text); if (!ready || ready[1] !== protocolToken) return false; + consumeProtocol(line.length); + if (stopReason) return true; + if (protocolReady) { + failureDetail ??= 'Deferred output emitted a duplicate READY frame.'; + stop('capture-failure'); + return true; + } + protocolReady = true; + const readyToken = ready[1]!; void decodeOutput().then(() => { if (decodedOutput && !stopReason) { - void runControl(['exec', profile.name, 'touch', `/run/codeboost-output/collected-${ready[1]}`]) + void acknowledgeDeferredOutput(readyToken) .then(success => { if (!success) { failureDetail ??= 'Deferred output acknowledgement failed.'; @@ -376,6 +416,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super return true; }; const captureStderr = (value: Buffer | string) => { + if (stopReason || closed) return; if (!profile.deferredOutput) { capture('stderr', value); return; } const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); protocolBuffer = Buffer.concat([protocolBuffer, chunk]); @@ -416,7 +457,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super protocolBuffer = Buffer.alloc(0); } closed = true; - let finalStdout = Buffer.concat(stdoutChunks, stdoutBytes), finalStderr = Buffer.concat(stderrChunks, stderrBytes); + let finalStdout = Buffer.concat(stdoutChunks, stdoutBytes), finalStderr = Buffer.concat(stderrChunks); let exitCode = code, finalSignal = signal; if (!stopReason && options.decode && !profile.deferredOutput) await decodeOutput(); if (decodePromise) await decodePromise; diff --git a/agents/container/probe.sh b/agents/container/probe.sh index f7503d4..fe17b30 100644 --- a/agents/container/probe.sh +++ b/agents/container/probe.sh @@ -67,6 +67,13 @@ case "$CODEBOOST_VENDOR" in require_option "$CODEX_HOME" nodev require_option "$CODEX_HOME/auth.json" ro require_ceiling "$CODEX_HOME" 4194304 256 + [ "$(findmnt --noheadings --output FSTYPE --target /run/codeboost-output)" = 'tmpfs' ] \ + || fail 'Codex output must use tmpfs' + require_option /run/codeboost-output rw + require_option /run/codeboost-output nosuid + require_option /run/codeboost-output nodev + require_option /run/codeboost-output noexec + require_ceiling /run/codeboost-output 20971520 64 ;; claude) [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || fail 'Claude credential is missing' @@ -79,6 +86,14 @@ esac [ "$(claude --version | awk '{print $1}')" = '2.1.281' ] || fail 'unexpected Claude version' if [ "${CODEBOOST_DEFERRED_OUTPUT:-}" = '1' ]; then + [ "$(findmnt --noheadings --output FSTYPE --target /run/codeboost-control)" = 'tmpfs' ] \ + || fail 'deferred control must use tmpfs' + require_option /run/codeboost-control rw + require_option /run/codeboost-control nosuid + require_option /run/codeboost-control nodev + require_option /run/codeboost-control noexec + require_ceiling /run/codeboost-control 65536 16 + [ ! -w /run/codeboost-control ] || fail 'agent must not write deferred control markers' token="$(cat /proc/sys/kernel/random/uuid)" printf '\036CODEBOOST_START:%s\036\n' "$token" >&2 set +e @@ -86,8 +101,9 @@ if [ "${CODEBOOST_DEFERRED_OUTPUT:-}" = '1' ]; then status="$?" set -e printf '\036CODEBOOST_READY:%s:%s\036\n' "$token" "$status" >&2 - acknowledgement="/run/codeboost-output/collected-$token" - while [ ! -e "$acknowledgement" ]; do sleep 0.05; done + acknowledgement="/run/codeboost-control/collected-$token" + while [ ! -f "$acknowledgement" ] || [ -L "$acknowledgement" ] \ + || [ "$(cat "$acknowledgement" 2>/dev/null || true)" != "$token" ]; do sleep 0.05; done exit "$status" fi exec "$@" diff --git a/agents/container/profile.ts b/agents/container/profile.ts index af19ee5..7925435 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -210,7 +210,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil '--mount', mount({ type: 'bind', source: inputIdentity.inputDirectory, target: '/run/codeboost-input', readonly: true })]; if (options.deferredOutput) { if (invocation.vendor !== 'codex') throw new Error('Deferred output is available only for Codex.'); - args.push('--env', 'CODEBOOST_DEFERRED_OUTPUT=1'); + args.push('--env', 'CODEBOOST_DEFERRED_OUTPUT=1', + '--tmpfs', '/run/codeboost-control:rw,nosuid,nodev,noexec,size=65536,nr_inodes=16,uid=0,gid=0,mode=0711'); } if (invocation.vendor === 'codex') { args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', diff --git a/agents/container/run.ts b/agents/container/run.ts index c3c1ce7..6990853 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -154,6 +154,8 @@ export function validateContainer(container: string, profile: ContainerProfile, ['rw', 'nosuid', 'nodev', 'size=4194304', 'nr_inodes=256', 'uid=10001', 'gid=10001', 'mode=0700']] as const, ['/run/codeboost-output', ['rw', 'nosuid', 'nodev', 'noexec', 'size=20971520', 'nr_inodes=64', 'uid=10001', 'gid=10001', 'mode=0700']] as const] : []), + ...(profile.deferredOutput ? [['/run/codeboost-control', + ['rw', 'nosuid', 'nodev', 'noexec', 'size=65536', 'nr_inodes=16', 'uid=0', 'gid=0', 'mode=0711']] as const] : []), ]); if (Object.keys(tmpfs).length !== expectedTmpfs.size) throw new Error('Container tmpfs mount set changed.'); for (const [path, expected] of expectedTmpfs) { diff --git a/agents/policy.ts b/agents/policy.ts index 5c39a85..7c02772 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -87,8 +87,8 @@ export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCo export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | 'persist-write' | 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker' | 'finite-output' | 'infinite-stdout' | 'infinite-stderr' | 'infinite-mixed' | 'ignore-term' | 'symlink-output' - | 'oversized-output' | 'fifo-output' | 'invalid-utf8-output' | 'replace-output-directory' | 'ack-failure' - | 'nonzero-output'; + | 'oversized-output' | 'fifo-output' | 'invalid-utf8-output' | 'replace-output-directory' + | 'nonzero-output' | 'duplicate-protocol'; /** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { @@ -121,8 +121,8 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 'fifo-output': 'mkfifo /run/codeboost-output/final.txt', 'invalid-utf8-output': "printf '\\377' > /run/codeboost-output/final.txt", 'replace-output-directory': 'rm -rf /run/codeboost-output; ln -s /etc /run/codeboost-output', - 'ack-failure': "printf captured > /run/codeboost-output/final.txt; chmod 0500 /run/codeboost-output", 'nonzero-output': 'printf encoded-output; exit 7', + 'duplicate-protocol': "printf '\\036CODEBOOST_START:00000000-0000-0000-0000-000000000000\\036\\n' >&2", }; return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 492efd5..9033b90 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -89,6 +89,19 @@ describe('container invocation supervisor', () => { expect(isInvocationActive(attemptId)).toBe(false); }, 60_000); + it('stops buffering deferred newline-free stderr after the limit is reached', async () => { + const attemptId = 'deferred-stderr-limit'; + const handle = startProfileInvocation(profile(fixture(), 'infinite-stderr', attemptId, 2 * 60_000, true), { + limits: { stdoutBytes: 64 * 1024, stderrBytes: 32 * 1024, combinedBytes: 64 * 1024 }, + decode: (current, _raw, maximum, timeoutMs, signal) => + readCodexOutput(current.name, maximum, timeoutMs, signal), + }); + const result = await handle.settled; + expect(result.stopReason).toBe('output-limit'); + expect(Buffer.byteLength(result.stderr)).toBeLessThanOrEqual(32 * 1024); + expect(isInvocationActive(attemptId)).toBe(false); + }, 60_000); + it('preserves the first cancellation reason until an ignored SIGTERM fully settles', async () => { const current = profile(fixture(), 'ignore-term', 'cancelled'); const handle = startProfileInvocation(current, { timeoutMs: 30_000 }); @@ -197,7 +210,7 @@ describe('container invocation supervisor', () => { ['fifo-output', 'capture-failure'], ['invalid-utf8-output', 'capture-failure'], ['replace-output-directory', 'capture-failure'], - ['ack-failure', 'capture-failure'], + ['duplicate-protocol', 'capture-failure'], ] as const)('rejects unsafe Codex output from %s', async (probe, reason) => { const handle = startProfileInvocation(profile(fixture(), probe, `file-${probe}`, 2 * 60_000, true), { limits: { stdoutBytes: 64 * 1024, stderrBytes: 64 * 1024, combinedBytes: 128 * 1024 }, From abffe7b39a50bf077dc74592b24bc79dd2169257 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 19:21:51 -0700 Subject: [PATCH 29/44] Retain colliding cleanup recovery --- agents/adapters/supervisor.ts | 9 +++++++-- agents/container/probe.sh | 2 +- agents/policy.ts | 3 ++- test/agent-supervisor.test.ts | 11 +++++++++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 46059a3..ee901f4 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -14,6 +14,7 @@ const DEFAULT_TIMEOUT_MS = 10 * 60_000; const CAPTURE_ABORT_GRACE_MS = 1_000; const DIAGNOSTIC_BYTES = 1024; const active = new Map(); +const cleanupRecoveries = new Set(); export interface CaptureLimits { readonly stdoutBytes: number; @@ -72,6 +73,7 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string, regis timer = undefined; complete = true; if (active.get(invocation.attemptId) === handle) active.delete(invocation.attemptId); + cleanupRecoveries.delete(handle); resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', stderr: diagnosticFor('capture-failure', detail).toString('utf8') })); @@ -85,6 +87,7 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string, regis handle = Object.freeze({ attemptId: invocation.attemptId, settled, cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); if (register) active.set(invocation.attemptId, handle); + else cleanupRecoveries.add(handle); schedule(); return handle; }; @@ -92,7 +95,7 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string, regis /** Retain attempt ownership while retrying a network allocated before profile construction failed. */ export function retainNetworkCleanup(invocation: InvocationInput, network: VendorNetwork, startupError: unknown, cleanupError: unknown): InvocationHandle { - if (active.has(invocation.attemptId)) throw cleanupError; + const register = !active.has(invocation.attemptId); let resolveSettled!: (result: InvocationResult) => void, cleaning = false, complete = false; let handle!: InvocationHandle; let timer: ReturnType | undefined; @@ -107,6 +110,7 @@ export function retainNetworkCleanup(invocation: InvocationInput, network: Vendo timer = undefined; complete = true; if (active.get(invocation.attemptId) === handle) active.delete(invocation.attemptId); + cleanupRecoveries.delete(handle); resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', stderr: diagnosticFor('capture-failure', detail).toString('utf8') })); @@ -122,7 +126,8 @@ export function retainNetworkCleanup(invocation: InvocationInput, network: Vendo }; handle = Object.freeze({ attemptId: invocation.attemptId, settled, cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); - active.set(invocation.attemptId, handle); + if (register) active.set(invocation.attemptId, handle); + else cleanupRecoveries.add(handle); timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); timer.unref(); return handle; } diff --git a/agents/container/probe.sh b/agents/container/probe.sh index fe17b30..da0e370 100644 --- a/agents/container/probe.sh +++ b/agents/container/probe.sh @@ -100,7 +100,7 @@ if [ "${CODEBOOST_DEFERRED_OUTPUT:-}" = '1' ]; then "$@" status="$?" set -e - printf '\036CODEBOOST_READY:%s:%s\036\n' "$token" "$status" >&2 + printf '\n\036CODEBOOST_READY:%s:%s\036\n' "$token" "$status" >&2 acknowledgement="/run/codeboost-control/collected-$token" while [ ! -f "$acknowledgement" ] || [ -L "$acknowledgement" ] \ || [ "$(cat "$acknowledgement" 2>/dev/null || true)" != "$token" ]; do sleep 0.05; done diff --git a/agents/policy.ts b/agents/policy.ts index 7c02772..833ebc9 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -88,7 +88,7 @@ export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | | 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker' | 'finite-output' | 'infinite-stdout' | 'infinite-stderr' | 'infinite-mixed' | 'ignore-term' | 'symlink-output' | 'oversized-output' | 'fifo-output' | 'invalid-utf8-output' | 'replace-output-directory' - | 'nonzero-output' | 'duplicate-protocol'; + | 'nonzero-output' | 'duplicate-protocol' | 'newline-free-deferred-output'; /** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { @@ -123,6 +123,7 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 'replace-output-directory': 'rm -rf /run/codeboost-output; ln -s /etc /run/codeboost-output', 'nonzero-output': 'printf encoded-output; exit 7', 'duplicate-protocol': "printf '\\036CODEBOOST_START:00000000-0000-0000-0000-000000000000\\036\\n' >&2", + 'newline-free-deferred-output': "printf captured > /run/codeboost-output/final.txt; printf trailing-diagnostic >&2", }; return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 9033b90..17552c9 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -242,6 +242,17 @@ describe('container invocation supervisor', () => { expect((await handle.settled).stopReason).toBe('capture-failure'); }, 60_000); + it('delimits READY after finite newline-free stderr', async () => { + const result = await startProfileInvocation( + profile(fixture(), 'newline-free-deferred-output', 'newline-free-ready', 2 * 60_000, true), { + decode: (current, _raw, maximum, timeoutMs, signal) => + readCodexOutput(current.name, maximum, timeoutMs, signal), + }).settled; + expect(result.stopReason, result.stderr).toBeUndefined(); + expect(result.stdout).toBe('captured'); + expect(result.stderr).toContain('trailing-diagnostic'); + }, 60_000); + if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { it('runs the production Codex adapter and collects its bounded output file', async () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; From c68aad2e9d5084ffba8f57ed647b43c44699a97f Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 19:35:10 -0700 Subject: [PATCH 30/44] Preserve adapter setup ownership --- agents/adapters/claude.ts | 36 +++++++++++++++++++++++++----- agents/adapters/codex.ts | 38 ++++++++++++++++++++++++++----- agents/adapters/supervisor.ts | 11 +++++++-- agents/container/profile.ts | 20 +++++++++++++++-- agents/network/network.ts | 28 ++++++++++++++++++----- test/agent-adapter.test.ts | 42 ++++++++++++++++++++++++++++++++--- 6 files changed, 152 insertions(+), 23 deletions(-) diff --git a/agents/adapters/claude.ts b/agents/adapters/claude.ts index 6b4c48e..834991e 100644 --- a/agents/adapters/claude.ts +++ b/agents/adapters/claude.ts @@ -1,8 +1,9 @@ import type { InvocationHandle } from '../contract.ts'; -import { createContainerProfile } from '../container/profile.ts'; -import { createVendorNetwork, removeVendorNetwork } from '../network/network.ts'; +import { createContainerProfile, ProfileCreationCleanupError } from '../container/profile.ts'; +import { createVendorNetwork, removeVendorNetwork, VendorNetworkCreationCleanupError, + type VendorNetwork } from '../network/network.ts'; import { createClaudeCommand, createPhasePolicy } from '../policy.ts'; -import { retainNetworkCleanup, startProfileInvocation } from './supervisor.ts'; +import { retainNetworkCleanup, retainSetupCleanup, startProfileInvocation } from './supervisor.ts'; import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; export function parseClaudeOutput(raw: Buffer): { text: string; providerFailed: boolean } { @@ -17,13 +18,38 @@ export function startClaudeInvocation(request: AgentAdapterRequest, oauthToken: string, options: AgentAdapterOptions = {}): InvocationHandle { if (!oauthToken || oauthToken.includes('\0')) throw new Error('Claude OAuth token is malformed.'); const policy = createPhasePolicy(request.invocation); - const network = createVendorNetwork(request.invocation, request.imageId); + const remaining = () => { + const value = request.invocation.deadline - Date.now(); + if (!Number.isSafeInteger(value) || value < 1) + throw new Error('Invocation deadline expired during adapter setup.'); + return Math.min(60_000, value); + }; + let network: VendorNetwork; + try { network = createVendorNetwork(request.invocation, request.imageId, remaining()); } + catch (error) { + if (error instanceof VendorNetworkCreationCleanupError) + return retainSetupCleanup(request.invocation, error.retryCleanup, error.startupError, error, + 'network creation cleanup'); + throw error; + } try { const profile = createContainerProfile({ ...request, policy, network, - command: createClaudeCommand(policy, request.prompt), claudeToken: oauthToken }); + command: createClaudeCommand(policy, request.prompt), claudeToken: oauthToken, timeoutMs: remaining() }); return startProfileInvocation(profile, { ...options, secrets: { CLAUDE_CODE_OAUTH_TOKEN: oauthToken }, decode: (_profile, raw) => parseClaudeOutput(raw) }); } catch (error) { + if (error instanceof ProfileCreationCleanupError) { + const retryCleanup = () => { + const failures: unknown[] = []; + try { error.retryCleanup(); } catch (cleanupError) { failures.push(cleanupError); } + try { removeVendorNetwork(network); } catch (cleanupError) { failures.push(cleanupError); } + if (failures.length) throw new AggregateError(failures, 'Adapter setup cleanup did not settle.'); + }; + try { retryCleanup(); } + catch (cleanupError) { return retainSetupCleanup(request.invocation, retryCleanup, + error.startupError, cleanupError, 'profile and network cleanup'); } + throw error.startupError; + } try { removeVendorNetwork(network); } catch (cleanupError) { return retainNetworkCleanup(request.invocation, network, error, cleanupError); } throw error; diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts index 777f9ef..8c5d1f6 100644 --- a/agents/adapters/codex.ts +++ b/agents/adapters/codex.ts @@ -1,8 +1,10 @@ import type { InvocationHandle } from '../contract.ts'; -import { createContainerProfile } from '../container/profile.ts'; -import { createVendorNetwork, removeVendorNetwork } from '../network/network.ts'; +import { createContainerProfile, ProfileCreationCleanupError } from '../container/profile.ts'; +import { createVendorNetwork, removeVendorNetwork, VendorNetworkCreationCleanupError, + type VendorNetwork } from '../network/network.ts'; import { createCodexCommand, createPhasePolicy } from '../policy.ts'; -import { readBoundedContainerFile, retainNetworkCleanup, startProfileInvocation } from './supervisor.ts'; +import { readBoundedContainerFile, retainNetworkCleanup, retainSetupCleanup, + startProfileInvocation } from './supervisor.ts'; import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; export const CODEX_OUTPUT_FILE = '/run/codeboost-output/final.txt'; @@ -18,14 +20,40 @@ export function startCodexInvocation(request: AgentAdapterRequest, authFile: string, options: AgentAdapterOptions = {}): InvocationHandle { if (!authFile || authFile.includes('\0')) throw new Error('Codex auth path is malformed.'); const policy = createPhasePolicy(request.invocation); - const network = createVendorNetwork(request.invocation, request.imageId); + const remaining = () => { + const value = request.invocation.deadline - Date.now(); + if (!Number.isSafeInteger(value) || value < 1) + throw new Error('Invocation deadline expired during adapter setup.'); + return Math.min(60_000, value); + }; + let network: VendorNetwork; + try { network = createVendorNetwork(request.invocation, request.imageId, remaining()); } + catch (error) { + if (error instanceof VendorNetworkCreationCleanupError) + return retainSetupCleanup(request.invocation, error.retryCleanup, error.startupError, error, + 'network creation cleanup'); + throw error; + } try { const profile = createContainerProfile({ ...request, policy, network, - command: createCodexCommand(policy, request.prompt), codexAuthFile: authFile, deferredOutput: true }); + command: createCodexCommand(policy, request.prompt), codexAuthFile: authFile, deferredOutput: true, + timeoutMs: remaining() }); return startProfileInvocation(profile, { ...options, decode: (current, _raw, maximum, timeoutMs, signal) => readCodexOutput(current.name, maximum, timeoutMs, signal) }); } catch (error) { + if (error instanceof ProfileCreationCleanupError) { + const retryCleanup = () => { + const failures: unknown[] = []; + try { error.retryCleanup(); } catch (cleanupError) { failures.push(cleanupError); } + try { removeVendorNetwork(network); } catch (cleanupError) { failures.push(cleanupError); } + if (failures.length) throw new AggregateError(failures, 'Adapter setup cleanup did not settle.'); + }; + try { retryCleanup(); } + catch (cleanupError) { return retainSetupCleanup(request.invocation, retryCleanup, + error.startupError, cleanupError, 'profile and network cleanup'); } + throw error.startupError; + } try { removeVendorNetwork(network); } catch (cleanupError) { return retainNetworkCleanup(request.invocation, network, error, cleanupError); } throw error; diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index ee901f4..f8d8988 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -95,17 +95,24 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string, regis /** Retain attempt ownership while retrying a network allocated before profile construction failed. */ export function retainNetworkCleanup(invocation: InvocationInput, network: VendorNetwork, startupError: unknown, cleanupError: unknown): InvocationHandle { + return retainSetupCleanup(invocation, () => removeVendorNetwork(network), startupError, cleanupError, + 'network cleanup'); +} + +/** Retain attempt ownership while retrying resources allocated during synchronous adapter setup. */ +export function retainSetupCleanup(invocation: InvocationInput, retryCleanup: () => void, + startupError: unknown, cleanupError: unknown, kind = 'setup cleanup'): InvocationHandle { const register = !active.has(invocation.attemptId); let resolveSettled!: (result: InvocationResult) => void, cleaning = false, complete = false; let handle!: InvocationHandle; let timer: ReturnType | undefined; const settled = new Promise(resolve => { resolveSettled = resolve; }); - const detail = `Adapter startup failed and network cleanup remains unsettled: ${String(startupError)}; ${String(cleanupError)}`; + const detail = `Adapter startup failed and ${kind} remains unsettled: ${String(startupError)}; ${String(cleanupError)}`; const retry = () => { if (cleaning || complete) return; cleaning = true; try { - removeVendorNetwork(network); + retryCleanup(); if (timer) clearTimeout(timer); timer = undefined; complete = true; diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 7925435..3689899 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -34,6 +34,19 @@ export interface ProfileOptions { readonly network: VendorNetwork; readonly policy: PhasePolicy; readonly deferredOutput?: boolean; + /** Remaining invocation budget for Docker-backed profile validation. */ + readonly timeoutMs?: number; +} + +export class ProfileCreationCleanupError extends AggregateError { + readonly startupError: unknown; + readonly retryCleanup: () => void; + + constructor(startupError: unknown, cleanupError: unknown, retryCleanup: () => void) { + super([startupError, cleanupError], 'Profile creation and cleanup both failed.'); + this.startupError = startupError; + this.retryCleanup = retryCleanup; + } } interface FileIdentity { @@ -154,7 +167,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container profile requires the immutable built image ID.'); assertBuiltAgentImage(options.imageId); assertTaskFilesystems(filesystems, invocation.clone); - assertVendorNetwork(options.network, invocation); + assertVendorNetwork(options.network, invocation, undefined, options.timeoutMs); if (claimedNetworks.has(options.network)) throw new Error('Vendor network already belongs to another container profile.'); assertPhasePolicy(options.policy, invocation); const command = assertAgentCommand(options.command, options.policy, invocation.vendor); @@ -234,7 +247,10 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil return profile; } catch (error) { try { removeOwnedDirectories(cleanupDirectories); } - catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Profile creation and cleanup both failed.'); } + catch (cleanupError) { + throw new ProfileCreationCleanupError(error, cleanupError, + () => removeOwnedDirectories(cleanupDirectories)); + } throw error; } } diff --git a/agents/network/network.ts b/agents/network/network.ts index 0cda964..84252b9 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -16,6 +16,16 @@ export interface VendorNetwork { } interface NetworkIdentity { readonly allocationId: string; readonly imageId: string; readonly invocation: InvocationInput; readonly subnet: string; readonly proxyIp: string } +export class VendorNetworkCreationCleanupError extends AggregateError { + readonly startupError: unknown; + readonly retryCleanup: () => void; + + constructor(startupError: unknown, cleanupError: unknown, retryCleanup: () => void) { + super([startupError, cleanupError], 'Vendor network creation and cleanup failed.'); + this.startupError = startupError; + this.retryCleanup = retryCleanup; + } +} const identities = new WeakMap(); const removedNetworks = new WeakSet(); const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); @@ -124,6 +134,14 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string const subnetSeed = randomUUID().replaceAll('-', ''); const subnet = `10.254.${parseInt(subnetSeed.slice(0, 2), 16)}.${parseInt(subnetSeed.slice(2, 4), 16) & 0xf8}/29`; let networkPlanned = false, proxyPlanned = false; + const cleanupPlannedResources = () => { + const failures: unknown[] = []; + if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer], + deadline(30_000), 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + if (networkPlanned) try { remove(['network', 'rm', name], ['network', 'inspect', name], + deadline(30_000), 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + if (failures.length) throw new AggregateError(failures, 'Vendor network cleanup did not settle.'); + }; try { networkPlanned = true; docker(['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, @@ -152,12 +170,10 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string remaining(); return network; } catch (error) { - const failures: unknown[] = []; - if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer], - deadline(30_000), 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } - if (networkPlanned) try { remove(['network', 'rm', name], ['network', 'inspect', name], - deadline(30_000), 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } - if (failures.length) throw new AggregateError([error, ...failures], 'Vendor network creation and cleanup failed.'); + try { cleanupPlannedResources(); } + catch (cleanupError) { + throw new VendorNetworkCreationCleanupError(error, cleanupError, cleanupPlannedResources); + } throw error; } } diff --git a/test/agent-adapter.test.ts b/test/agent-adapter.test.ts index c79af29..799ecc0 100644 --- a/test/agent-adapter.test.ts +++ b/test/agent-adapter.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { parseClaudeOutput } from '../agents/adapters/claude.ts'; -import { CODEX_OUTPUT_FILE } from '../agents/adapters/codex.ts'; -import { OUTPUT_LIMITS } from '../agents/adapters/supervisor.ts'; +import { parseClaudeOutput, startClaudeInvocation } from '../agents/adapters/claude.ts'; +import { CODEX_OUTPUT_FILE, startCodexInvocation } from '../agents/adapters/codex.ts'; +import { isInvocationActive, OUTPUT_LIMITS, retainSetupCleanup } from '../agents/adapters/supervisor.ts'; import { captureInvocation } from '../agents/contract.ts'; import { createCodexCommand, createPhasePolicy } from '../agents/policy.ts'; describe('production agent adapters', () => { + const capturedInvocation = (attemptId: string, deadline: number, vendor: 'codex' | 'claude' = 'codex') => + captureInvocation({ + clone: { id: 'clone', taskId: 'task', directory: '/tmp/task', head: 'a'.repeat(40) }, + phase: 'planning', vendor, approvedArgv: [], deadline, attemptId, + context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', + referencedCodeHash: 'c', stateVersion: 1 }, + }, deadline - 1); + it('parses recorded Claude success and failure envelopes', () => { expect(parseClaudeOutput(Buffer.from('{"result":"planned","is_error":false}'))) .toEqual({ text: 'planned', providerFailed: false }); @@ -32,4 +40,32 @@ describe('production agent adapters', () => { combinedBytes: 20 * 1024 * 1024 }); expect(Object.isFrozen(OUTPUT_LIMITS)).toBe(true); }); + + it.each(['codex', 'claude'] as const)('rejects expired %s setup before allocating a network', vendor => { + const invocation = capturedInvocation(`expired-${vendor}`, Date.now() - 1, vendor); + const request = { invocation, filesystems: {} as never, inputDirectory: '/unused', + imageId: `sha256:${'a'.repeat(64)}`, prompt: 'unused' }; + const start = () => vendor === 'codex' + ? startCodexInvocation(request, '/unused/auth.json') + : startClaudeInvocation(request, 'token'); + expect(start).toThrow('deadline expired during adapter setup'); + expect(isInvocationActive(invocation.attemptId)).toBe(false); + }); + + it('retains setup cleanup ownership until a retry succeeds', async () => { + const invocation = capturedInvocation('setup-recovery', Date.now() + 60_000); + let attempts = 0; + const handle = retainSetupCleanup(invocation, () => { + attempts += 1; + if (attempts === 1) throw new Error('still busy'); + }, new Error('startup failed'), new Error('cleanup failed')); + expect(isInvocationActive(invocation.attemptId)).toBe(true); + handle.cancel('cancelled'); + expect(isInvocationActive(invocation.attemptId)).toBe(true); + handle.cancel('cancelled'); + const result = await handle.settled; + expect(result.stopReason).toBe('capture-failure'); + expect(result.stderr).toContain('setup cleanup remains unsettled'); + expect(isInvocationActive(invocation.attemptId)).toBe(false); + }); }); From 796f3ca7c1f124d29535e2942b44e4e35c5ee1de Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 19:44:13 -0700 Subject: [PATCH 31/44] Serialize agent isolation CI tests --- .github/workflows/agent-isolation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index ad60d91..c705191 100644 --- a/.github/workflows/agent-isolation.yml +++ b/.github/workflows/agent-isolation.yml @@ -22,4 +22,4 @@ jobs: cache: npm - run: npm ci --ignore-scripts - run: npm run typecheck - - run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts test/agent-network.test.ts test/agent-policy.test.ts test/agent-adapter.test.ts test/agent-supervisor.test.ts + - run: npx vitest run --no-file-parallelism test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts test/agent-network.test.ts test/agent-policy.test.ts test/agent-adapter.test.ts test/agent-supervisor.test.ts From 7b25c0e998ea9c69fe532eacfa0ac0da053749a6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 20:00:02 -0700 Subject: [PATCH 32/44] Bound cancellation with monotonic deadlines --- agents/adapters/claude.ts | 9 ++------- agents/adapters/codex.ts | 9 ++------- agents/adapters/supervisor.ts | 18 ++++++++++++------ agents/adapters/types.ts | 17 +++++++++++++++++ test/agent-adapter.test.ts | 14 +++++++++++++- test/agent-supervisor.test.ts | 16 ++++++++++++++++ 6 files changed, 62 insertions(+), 21 deletions(-) diff --git a/agents/adapters/claude.ts b/agents/adapters/claude.ts index 834991e..a79f47e 100644 --- a/agents/adapters/claude.ts +++ b/agents/adapters/claude.ts @@ -4,7 +4,7 @@ import { createVendorNetwork, removeVendorNetwork, VendorNetworkCreationCleanupE type VendorNetwork } from '../network/network.ts'; import { createClaudeCommand, createPhasePolicy } from '../policy.ts'; import { retainNetworkCleanup, retainSetupCleanup, startProfileInvocation } from './supervisor.ts'; -import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; +import { createInvocationBudget, type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts'; export function parseClaudeOutput(raw: Buffer): { text: string; providerFailed: boolean } { const envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)) as @@ -18,12 +18,7 @@ export function startClaudeInvocation(request: AgentAdapterRequest, oauthToken: string, options: AgentAdapterOptions = {}): InvocationHandle { if (!oauthToken || oauthToken.includes('\0')) throw new Error('Claude OAuth token is malformed.'); const policy = createPhasePolicy(request.invocation); - const remaining = () => { - const value = request.invocation.deadline - Date.now(); - if (!Number.isSafeInteger(value) || value < 1) - throw new Error('Invocation deadline expired during adapter setup.'); - return Math.min(60_000, value); - }; + const remaining = createInvocationBudget(request.invocation, 60_000); let network: VendorNetwork; try { network = createVendorNetwork(request.invocation, request.imageId, remaining()); } catch (error) { diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts index 8c5d1f6..f0efc26 100644 --- a/agents/adapters/codex.ts +++ b/agents/adapters/codex.ts @@ -5,7 +5,7 @@ import { createVendorNetwork, removeVendorNetwork, VendorNetworkCreationCleanupE import { createCodexCommand, createPhasePolicy } from '../policy.ts'; import { readBoundedContainerFile, retainNetworkCleanup, retainSetupCleanup, startProfileInvocation } from './supervisor.ts'; -import type { AgentAdapterOptions, AgentAdapterRequest } from './types.ts'; +import { createInvocationBudget, type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts'; export const CODEX_OUTPUT_FILE = '/run/codeboost-output/final.txt'; @@ -20,12 +20,7 @@ export function startCodexInvocation(request: AgentAdapterRequest, authFile: string, options: AgentAdapterOptions = {}): InvocationHandle { if (!authFile || authFile.includes('\0')) throw new Error('Codex auth path is malformed.'); const policy = createPhasePolicy(request.invocation); - const remaining = () => { - const value = request.invocation.deadline - Date.now(); - if (!Number.isSafeInteger(value) || value < 1) - throw new Error('Invocation deadline expired during adapter setup.'); - return Math.min(60_000, value); - }; + const remaining = createInvocationBudget(request.invocation, 60_000); let network: VendorNetwork; try { network = createVendorNetwork(request.invocation, request.imageId, remaining()); } catch (error) { diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index f8d8988..8e74399 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -218,12 +218,13 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super } catch (error) { return rejectWithCleanup(error); } - const now = Date.now(), deadline = Math.min(invocation.deadline, now + configuredTimeout); - if (!Number.isSafeInteger(deadline) || deadline <= now) { + const wallRemaining = invocation.deadline - Date.now(); + if (!Number.isSafeInteger(wallRemaining) || wallRemaining < 1) { return rejectWithCleanup(new Error('Invocation deadline has already expired.')); } + const duration = Math.min(wallRemaining, configuredTimeout), deadline = performance.now() + duration; const remaining = () => { - const value = deadline - Date.now(); + const value = Math.ceil(deadline - performance.now()); if (value < 1) throw new Error('Invocation deadline has already expired.'); return value; }; @@ -332,10 +333,15 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super if (!options.decode || decodedOutput || stopReason) return Promise.resolve(); decodePromise = (async () => { try { - const budget = deadline - Date.now(); + const budget = Math.ceil(deadline - performance.now()); if (budget < 1) throw new CaptureDeadlineError('Invocation deadline expired before output capture.'); const controller = new AbortController(); decodeAbort = controller; + const aborted = new Promise((_resolve, reject) => { + controller.signal.addEventListener('abort', () => reject(stopReason === 'timeout' + ? new CaptureDeadlineError('Adapter output capture exceeded the invocation deadline.') + : new Error('Adapter output capture was cancelled.')), { once: true }); + }); const raw = Buffer.concat(stdoutChunks, stdoutBytes); const operation = Promise.resolve(options.decode!(profile, raw, Math.max(1, Math.min(limits.stdoutBytes - stdoutBytes, limits.combinedBytes - combinedBytes)), @@ -349,7 +355,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super decodeTimer.unref(); }); let decoded: DecodedOutput; - try { decoded = await Promise.race([operation, timeout]); } + try { decoded = await Promise.race([operation, timeout, aborted]); } catch (error) { controller.abort(); let graceTimer: ReturnType | undefined; @@ -449,7 +455,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super child.stdout?.once('error', error => { failureDetail ??= error.message; stop('capture-failure'); }); child.stderr?.once('error', error => { failureDetail ??= error.message; stop('capture-failure'); }); child.once('error', error => { failureDetail ??= error.message; stop('capture-failure'); }); - later(() => stop('timeout'), Math.max(1, deadline - Date.now())); + later(() => stop('timeout'), Math.max(1, Math.ceil(deadline - performance.now()))); let resolveSettled!: (result: InvocationResult) => void; let wakeCleanup: (() => void) | undefined; diff --git a/agents/adapters/types.ts b/agents/adapters/types.ts index ab942c7..bcbaeaf 100644 --- a/agents/adapters/types.ts +++ b/agents/adapters/types.ts @@ -13,3 +13,20 @@ export interface AgentAdapterOptions { readonly timeoutMs?: number; readonly limits?: Partial; } + +/** Convert an absolute wall-clock deadline once, then enforce it with a monotonic clock. */ +export function createInvocationBudget(invocation: InvocationInput, maximumMs: number): () => number { + if (!Number.isSafeInteger(maximumMs) || maximumMs < 1) + throw new Error('Invocation setup budget must be a positive integer.'); + const wallRemaining = invocation.deadline - Date.now(); + if (!Number.isSafeInteger(wallRemaining) || wallRemaining < 1) + throw new Error('Invocation deadline expired during adapter setup.'); + const duration = Math.min(maximumMs, wallRemaining); + const end = performance.now() + duration; + return () => { + const value = Math.ceil(end - performance.now()); + if (!Number.isSafeInteger(value) || value < 1) + throw new Error('Invocation deadline expired during adapter setup.'); + return value; + }; +} diff --git a/test/agent-adapter.test.ts b/test/agent-adapter.test.ts index 799ecc0..ddda579 100644 --- a/test/agent-adapter.test.ts +++ b/test/agent-adapter.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { parseClaudeOutput, startClaudeInvocation } from '../agents/adapters/claude.ts'; import { CODEX_OUTPUT_FILE, startCodexInvocation } from '../agents/adapters/codex.ts'; import { isInvocationActive, OUTPUT_LIMITS, retainSetupCleanup } from '../agents/adapters/supervisor.ts'; +import { createInvocationBudget } from '../agents/adapters/types.ts'; import { captureInvocation } from '../agents/contract.ts'; import { createCodexCommand, createPhasePolicy } from '../agents/policy.ts'; @@ -68,4 +69,15 @@ describe('production agent adapters', () => { expect(result.stderr).toContain('setup cleanup remains unsettled'); expect(isInvocationActive(invocation.attemptId)).toBe(false); }); + + it('does not extend an invocation budget when the wall clock moves backward', () => { + const wall = Date.now(); + const invocation = capturedInvocation('monotonic-budget', wall + 5_000); + const clock = vi.spyOn(Date, 'now').mockReturnValueOnce(wall).mockReturnValue(wall - 60_000); + try { + const remaining = createInvocationBudget(invocation, 1_000); + expect(remaining()).toBeGreaterThan(0); + expect(remaining()).toBeLessThanOrEqual(1_000); + } finally { clock.mockRestore(); } + }); }); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 17552c9..f4e72ec 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -181,6 +181,22 @@ describe('container invocation supervisor', () => { expect(isInvocationActive('decode-ignores-abort')).toBe(false); }, 30_000); + it('settles cancellation promptly when an injected decoder ignores abort', async () => { + let begin!: () => void; + const started = new Promise(resolve => { begin = resolve; }); + const handle = startProfileInvocation(profile(fixture(), 'finite-output', 'cancel-ignored-decode', 30_000), { + timeoutMs: 30_000, + decode: () => { begin(); return new Promise(() => {}); }, + }); + await started; + const cancelledAt = performance.now(); + handle.cancel('cancelled'); + const result = await handle.settled; + expect(result.stopReason).toBe('cancelled'); + expect(performance.now() - cancelledAt).toBeLessThan(5_000); + expect(isInvocationActive('cancel-ignored-decode')).toBe(false); + }, 15_000); + it('validates and decodes provider output even when the process exits nonzero', async () => { const result = await startProfileInvocation(profile(fixture(), 'nonzero-output', 'nonzero-decode'), { decode: (_current, raw) => ({ text: `decoded:${raw.toString('utf8')}` }), From 066e92e2971ee7db687cf27e3febc36787f0bf00 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 20:19:32 -0700 Subject: [PATCH 33/44] Carry invocation ownership through settlement --- agents/adapters/claude.ts | 8 +++++--- agents/adapters/codex.ts | 7 ++++--- agents/adapters/supervisor.ts | 29 ++++++++++++++++++++--------- test/agent-adapter.test.ts | 21 +++++++++++++-------- test/agent-supervisor.test.ts | 13 +++++++++++++ 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/agents/adapters/claude.ts b/agents/adapters/claude.ts index a79f47e..5e3f965 100644 --- a/agents/adapters/claude.ts +++ b/agents/adapters/claude.ts @@ -18,9 +18,9 @@ export function startClaudeInvocation(request: AgentAdapterRequest, oauthToken: string, options: AgentAdapterOptions = {}): InvocationHandle { if (!oauthToken || oauthToken.includes('\0')) throw new Error('Claude OAuth token is malformed.'); const policy = createPhasePolicy(request.invocation); - const remaining = createInvocationBudget(request.invocation, 60_000); + const remaining = createInvocationBudget(request.invocation, 10 * 60_000); let network: VendorNetwork; - try { network = createVendorNetwork(request.invocation, request.imageId, remaining()); } + try { network = createVendorNetwork(request.invocation, request.imageId, Math.min(60_000, remaining())); } catch (error) { if (error instanceof VendorNetworkCreationCleanupError) return retainSetupCleanup(request.invocation, error.retryCleanup, error.startupError, error, @@ -29,8 +29,10 @@ export function startClaudeInvocation(request: AgentAdapterRequest, } try { const profile = createContainerProfile({ ...request, policy, network, - command: createClaudeCommand(policy, request.prompt), claudeToken: oauthToken, timeoutMs: remaining() }); + command: createClaudeCommand(policy, request.prompt), claudeToken: oauthToken, + timeoutMs: Math.min(60_000, remaining()) }); return startProfileInvocation(profile, { ...options, secrets: { CLAUDE_CODE_OAUTH_TOKEN: oauthToken }, + invocationBudget: remaining, decode: (_profile, raw) => parseClaudeOutput(raw) }); } catch (error) { if (error instanceof ProfileCreationCleanupError) { diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts index f0efc26..8bd1672 100644 --- a/agents/adapters/codex.ts +++ b/agents/adapters/codex.ts @@ -20,9 +20,9 @@ export function startCodexInvocation(request: AgentAdapterRequest, authFile: string, options: AgentAdapterOptions = {}): InvocationHandle { if (!authFile || authFile.includes('\0')) throw new Error('Codex auth path is malformed.'); const policy = createPhasePolicy(request.invocation); - const remaining = createInvocationBudget(request.invocation, 60_000); + const remaining = createInvocationBudget(request.invocation, 10 * 60_000); let network: VendorNetwork; - try { network = createVendorNetwork(request.invocation, request.imageId, remaining()); } + try { network = createVendorNetwork(request.invocation, request.imageId, Math.min(60_000, remaining())); } catch (error) { if (error instanceof VendorNetworkCreationCleanupError) return retainSetupCleanup(request.invocation, error.retryCleanup, error.startupError, error, @@ -32,8 +32,9 @@ export function startCodexInvocation(request: AgentAdapterRequest, try { const profile = createContainerProfile({ ...request, policy, network, command: createCodexCommand(policy, request.prompt), codexAuthFile: authFile, deferredOutput: true, - timeoutMs: remaining() }); + timeoutMs: Math.min(60_000, remaining()) }); return startProfileInvocation(profile, { ...options, + invocationBudget: remaining, decode: (current, _raw, maximum, timeoutMs, signal) => readCodexOutput(current.name, maximum, timeoutMs, signal) }); } catch (error) { diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 8e74399..4401fe9 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -15,6 +15,9 @@ const CAPTURE_ABORT_GRACE_MS = 1_000; const DIAGNOSTIC_BYTES = 1024; const active = new Map(); const cleanupRecoveries = new Set(); +const hasCleanupRecovery = (attemptId: string) => + Array.from(cleanupRecoveries).some(handle => handle.attemptId === attemptId); +const ownsAttempt = (attemptId: string) => active.has(attemptId) || hasCleanupRecovery(attemptId); export interface CaptureLimits { readonly stdoutBytes: number; @@ -31,6 +34,8 @@ export interface SupervisorOptions { readonly secrets?: Readonly>; readonly timeoutMs?: number; readonly limits?: Partial; + /** Trusted monotonic budget carried from synchronous adapter setup. */ + readonly invocationBudget?: () => number; readonly decode?: (profile: ContainerProfile, rawStdout: Buffer, maximumBytes: number, timeoutMs: number, signal: AbortSignal) => DecodedOutput | Promise; } @@ -62,7 +67,6 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string, regis const schedule = () => { if (timer) return; timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); - timer.unref(); }; const retry = () => { if (cleaning || complete) return; @@ -102,7 +106,7 @@ export function retainNetworkCleanup(invocation: InvocationInput, network: Vendo /** Retain attempt ownership while retrying resources allocated during synchronous adapter setup. */ export function retainSetupCleanup(invocation: InvocationInput, retryCleanup: () => void, startupError: unknown, cleanupError: unknown, kind = 'setup cleanup'): InvocationHandle { - const register = !active.has(invocation.attemptId); + const register = !ownsAttempt(invocation.attemptId); let resolveSettled!: (result: InvocationResult) => void, cleaning = false, complete = false; let handle!: InvocationHandle; let timer: ReturnType | undefined; @@ -125,7 +129,6 @@ export function retainSetupCleanup(invocation: InvocationInput, retryCleanup: () cleaning = false; if (!timer) { timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); - timer.unref(); } return; } @@ -135,7 +138,7 @@ export function retainSetupCleanup(invocation: InvocationInput, retryCleanup: () cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); if (register) active.set(invocation.attemptId, handle); else cleanupRecoveries.add(handle); - timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); timer.unref(); + timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); return handle; } const withDiagnostic = (stderr: Buffer, stdoutBytes: number, reason: StopReason, limits: CaptureLimits, @@ -192,7 +195,7 @@ export function readBoundedContainerFile(container: string, source: string, maxi } export function isInvocationActive(attemptId: string): boolean { - return active.has(attemptId); + return ownsAttempt(attemptId); } export function startProfileInvocation(profile: ContainerProfile, options: SupervisorOptions = {}): InvocationHandle { @@ -205,16 +208,20 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super } throw error; }; - if (active.has(invocation.attemptId)) { + if (ownsAttempt(invocation.attemptId)) { return rejectWithCleanup(new Error('An invocation with this attempt ID is still active.'), false); } - let limits: CaptureLimits, configuredTimeout: number; + let limits: CaptureLimits, configuredTimeout: number, carriedBudget: number; try { limits = captureLimits(options.limits); configuredTimeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; positiveInteger(configuredTimeout, 'timeoutMs'); if (configuredTimeout > DEFAULT_TIMEOUT_MS) throw new Error('timeoutMs cannot exceed the production ten-minute ceiling.'); + carriedBudget = options.invocationBudget?.() ?? DEFAULT_TIMEOUT_MS; + positiveInteger(carriedBudget, 'invocationBudget'); + if (carriedBudget > DEFAULT_TIMEOUT_MS) + throw new Error('invocationBudget cannot exceed the production ten-minute ceiling.'); } catch (error) { return rejectWithCleanup(error); } @@ -222,7 +229,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super if (!Number.isSafeInteger(wallRemaining) || wallRemaining < 1) { return rejectWithCleanup(new Error('Invocation deadline has already expired.')); } - const duration = Math.min(wallRemaining, configuredTimeout), deadline = performance.now() + duration; + const duration = Math.min(wallRemaining, configuredTimeout, carriedBudget), deadline = performance.now() + duration; const remaining = () => { const value = Math.ceil(deadline - performance.now()); if (value < 1) throw new Error('Invocation deadline has already expired.'); @@ -372,6 +379,8 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super if (decodeAbort === controller) decodeAbort = undefined; } if (stopReason) return; + if (performance.now() >= deadline) + throw new CaptureDeadlineError('Invocation deadline expired while decoding output.'); const additional = decoded.additionalBytes ?? 0; const textBytes = Buffer.byteLength(decoded.text); if (!Number.isSafeInteger(additional) || additional < 0) @@ -381,6 +390,8 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super throw new OutputLimitError('Decoded adapter output exceeds its capture limit.'); if (stdoutBytes + additional > limits.stdoutBytes || combinedBytes + additional > limits.combinedBytes) throw new OutputLimitError('Adapter output exceeds its capture limit.'); + if (performance.now() >= deadline) + throw new CaptureDeadlineError('Invocation deadline expired while validating decoded output.'); decodedOutput = decoded; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -498,7 +509,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super await new Promise(resolve => { let finished = false; const wake = () => { if (finished) return; finished = true; clearTimeout(timer); resolve(); }; - const timer = setTimeout(wake, 1_000); timer.unref(); + const timer = setTimeout(wake, 1_000); wakeCleanup = wake; }); } diff --git a/test/agent-adapter.test.ts b/test/agent-adapter.test.ts index ddda579..97c2b1c 100644 --- a/test/agent-adapter.test.ts +++ b/test/agent-adapter.test.ts @@ -53,18 +53,23 @@ describe('production agent adapters', () => { expect(isInvocationActive(invocation.attemptId)).toBe(false); }); - it('retains setup cleanup ownership until a retry succeeds', async () => { + it('retains every colliding setup cleanup owner until all retries succeed', async () => { const invocation = capturedInvocation('setup-recovery', Date.now() + 60_000); - let attempts = 0; - const handle = retainSetupCleanup(invocation, () => { - attempts += 1; - if (attempts === 1) throw new Error('still busy'); + let releaseFirst = false, releaseSecond = false; + const first = retainSetupCleanup(invocation, () => { + if (!releaseFirst) throw new Error('first still busy'); }, new Error('startup failed'), new Error('cleanup failed')); + const second = retainSetupCleanup(invocation, () => { + if (!releaseSecond) throw new Error('second still busy'); + }, new Error('duplicate startup failed'), new Error('duplicate cleanup failed')); expect(isInvocationActive(invocation.attemptId)).toBe(true); - handle.cancel('cancelled'); + releaseFirst = true; + first.cancel('cancelled'); + await first.settled; expect(isInvocationActive(invocation.attemptId)).toBe(true); - handle.cancel('cancelled'); - const result = await handle.settled; + releaseSecond = true; + second.cancel('cancelled'); + const result = await second.settled; expect(result.stopReason).toBe('capture-failure'); expect(result.stderr).toContain('setup cleanup remains unsettled'); expect(isInvocationActive(invocation.attemptId)).toBe(false); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index f4e72ec..9af74bd 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -197,6 +197,19 @@ describe('container invocation supervisor', () => { expect(isInvocationActive('cancel-ignored-decode')).toBe(false); }, 15_000); + it('does not publish a synchronous decode that finishes after the monotonic deadline', async () => { + const result = await startProfileInvocation(profile(fixture(), 'finite-output', 'decode-over-deadline', 30_000), { + timeoutMs: 3_000, + decode: (_current, _raw, _maximum, timeoutMs) => { + const end = performance.now() + timeoutMs + 50; + while (performance.now() < end) { /* deliberately block the timer queue */ } + return { text: 'must-not-publish' }; + }, + }).settled; + expect(result.stopReason).toBe('timeout'); + expect(result.stdout).not.toContain('must-not-publish'); + }, 15_000); + it('validates and decodes provider output even when the process exits nonzero', async () => { const result = await startProfileInvocation(profile(fixture(), 'nonzero-output', 'nonzero-decode'), { decode: (_current, raw) => ({ text: `decoded:${raw.toString('utf8')}` }), From 5b4de854a9a083101c4ee5dc6bfdb60b799b0a09 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 20:29:53 -0700 Subject: [PATCH 34/44] Bound failed network setup cleanup --- agents/network/network.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/agents/network/network.ts b/agents/network/network.ts index 84252b9..10c9ab2 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -134,12 +134,12 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string const subnetSeed = randomUUID().replaceAll('-', ''); const subnet = `10.254.${parseInt(subnetSeed.slice(0, 2), 16)}.${parseInt(subnetSeed.slice(2, 4), 16) & 0xf8}/29`; let networkPlanned = false, proxyPlanned = false; - const cleanupPlannedResources = () => { + const cleanupPlannedResources = (cleanupRemaining = deadline(30_000)) => { const failures: unknown[] = []; if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer], - deadline(30_000), 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + cleanupRemaining, 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } if (networkPlanned) try { remove(['network', 'rm', name], ['network', 'inspect', name], - deadline(30_000), 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + cleanupRemaining, 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } if (failures.length) throw new AggregateError(failures, 'Vendor network cleanup did not settle.'); }; try { @@ -170,7 +170,7 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string remaining(); return network; } catch (error) { - try { cleanupPlannedResources(); } + try { cleanupPlannedResources(remaining); } catch (cleanupError) { throw new VendorNetworkCreationCleanupError(error, cleanupError, cleanupPlannedResources); } From 267018df57946f979b8a12ec2a948a3b50c6ab60 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 20:42:03 -0700 Subject: [PATCH 35/44] Guard active profile and close deadline --- agents/adapters/supervisor.ts | 8 +++++++- test/agent-supervisor.test.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 4401fe9..8887c71 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -14,6 +14,7 @@ const DEFAULT_TIMEOUT_MS = 10 * 60_000; const CAPTURE_ABORT_GRACE_MS = 1_000; const DIAGNOSTIC_BYTES = 1024; const active = new Map(); +const activeProfiles = new WeakSet(); const cleanupRecoveries = new Set(); const hasCleanupRecovery = (attemptId: string) => Array.from(cleanupRecoveries).some(handle => handle.attemptId === attemptId); @@ -209,6 +210,8 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super throw error; }; if (ownsAttempt(invocation.attemptId)) { + if (activeProfiles.has(profile)) + throw new Error('This container profile already owns the active invocation.'); return rejectWithCleanup(new Error('An invocation with this attempt ID is still active.'), false); } let limits: CaptureLimits, configuredTimeout: number, carriedBudget: number; @@ -477,10 +480,12 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super cancel: (reason: StopReason) => { stop(reason); wakeCleanup?.(); }, }); active.set(invocation.attemptId, handle); + activeProfiles.add(profile); child.once('close', async (code, signal) => { for (const timer of timers) clearTimeout(timer); timers.clear(); + if (!stopReason && performance.now() >= deadline) stopReason = 'timeout'; if (protocolBuffer.length) { if (!protocolLine(protocolBuffer)) capture('stderr', protocolBuffer); protocolBuffer = Buffer.alloc(0); @@ -493,7 +498,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super if (!stopReason && profile.deferredOutput && !decodedOutput) { stopReason = 'capture-failure'; failureDetail ??= 'Deferred output protocol did not complete.'; } - if (decodedOutput) { + if (decodedOutput && !stopReason) { finalStdout = Buffer.from(decodedOutput.text); if (decodedOutput.providerFailed) exitCode = exitCode === 0 ? 1 : exitCode; } @@ -519,6 +524,7 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super exitCode, signal: finalSignal, ...(stopReason ? { stopReason } : {}), stdout: finalStdout.toString('utf8'), stderr: finalStderr.toString('utf8') }); settlementComplete = true; + activeProfiles.delete(profile); active.delete(invocation.attemptId); resolveSettled(result); }); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 9af74bd..35f76e3 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -125,6 +125,15 @@ describe('container invocation supervisor', () => { expect(isInvocationActive('timeout')).toBe(false); }, 60_000); + it('records timeout when close delivery resumes after the monotonic deadline', async () => { + const handle = startProfileInvocation(profile(fixture(), 'finite-output', 'late-close-delivery', 30_000), + { timeoutMs: 3_000 }); + const end = performance.now() + 3_500; + while (performance.now() < end) { /* delay both close and timer delivery */ } + const result = await handle.settled; + expect(result.stopReason).toBe('timeout'); + }, 15_000); + it('blocks a duplicate attempt while the original container remains active', async () => { const data = fixture(), first = startProfileInvocation(profile(data, 'ignore-term', 'duplicate'), { timeoutMs: 30_000 }); expect(() => startProfileInvocation(profile(data, 'finite-output', 'duplicate'))).toThrow('still active'); @@ -133,6 +142,16 @@ describe('container invocation supervisor', () => { expect((await first.settled).stopReason).toBe('shutdown'); }, 60_000); + it('rejects reuse of the same active profile without disposing its container', async () => { + const current = profile(fixture(), 'ignore-term', 'same-profile-duplicate'); + const first = startProfileInvocation(current, { timeoutMs: 30_000 }); + expect(() => startProfileInvocation(current)).toThrow('already owns the active invocation'); + expect(isInvocationActive('same-profile-duplicate')).toBe(true); + expect(spawnSync('docker', ['container', 'inspect', current.name]).status).toBe(0); + first.cancel('shutdown'); + expect((await first.settled).stopReason).toBe('shutdown'); + }, 60_000); + it('records decoder failure without publishing a successful result', async () => { const handle = startProfileInvocation(profile(fixture(), 'finite-output', 'capture-failure'), { decode: () => { throw new Error('simulated capture failure'); }, From 22f362e8c281aecea4a33b64a613f77aab112c61 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 20:52:05 -0700 Subject: [PATCH 36/44] Apply adapter timeout across setup --- agents/adapters/claude.ts | 4 ++-- agents/adapters/codex.ts | 4 ++-- agents/adapters/types.ts | 10 ++++++++++ test/agent-adapter.test.ts | 10 +++++++++- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/agents/adapters/claude.ts b/agents/adapters/claude.ts index 5e3f965..319f6eb 100644 --- a/agents/adapters/claude.ts +++ b/agents/adapters/claude.ts @@ -4,7 +4,7 @@ import { createVendorNetwork, removeVendorNetwork, VendorNetworkCreationCleanupE type VendorNetwork } from '../network/network.ts'; import { createClaudeCommand, createPhasePolicy } from '../policy.ts'; import { retainNetworkCleanup, retainSetupCleanup, startProfileInvocation } from './supervisor.ts'; -import { createInvocationBudget, type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts'; +import { createAdapterInvocationBudget, type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts'; export function parseClaudeOutput(raw: Buffer): { text: string; providerFailed: boolean } { const envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)) as @@ -18,7 +18,7 @@ export function startClaudeInvocation(request: AgentAdapterRequest, oauthToken: string, options: AgentAdapterOptions = {}): InvocationHandle { if (!oauthToken || oauthToken.includes('\0')) throw new Error('Claude OAuth token is malformed.'); const policy = createPhasePolicy(request.invocation); - const remaining = createInvocationBudget(request.invocation, 10 * 60_000); + const remaining = createAdapterInvocationBudget(request.invocation, options.timeoutMs); let network: VendorNetwork; try { network = createVendorNetwork(request.invocation, request.imageId, Math.min(60_000, remaining())); } catch (error) { diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts index 8bd1672..21d9e94 100644 --- a/agents/adapters/codex.ts +++ b/agents/adapters/codex.ts @@ -5,7 +5,7 @@ import { createVendorNetwork, removeVendorNetwork, VendorNetworkCreationCleanupE import { createCodexCommand, createPhasePolicy } from '../policy.ts'; import { readBoundedContainerFile, retainNetworkCleanup, retainSetupCleanup, startProfileInvocation } from './supervisor.ts'; -import { createInvocationBudget, type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts'; +import { createAdapterInvocationBudget, type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts'; export const CODEX_OUTPUT_FILE = '/run/codeboost-output/final.txt'; @@ -20,7 +20,7 @@ export function startCodexInvocation(request: AgentAdapterRequest, authFile: string, options: AgentAdapterOptions = {}): InvocationHandle { if (!authFile || authFile.includes('\0')) throw new Error('Codex auth path is malformed.'); const policy = createPhasePolicy(request.invocation); - const remaining = createInvocationBudget(request.invocation, 10 * 60_000); + const remaining = createAdapterInvocationBudget(request.invocation, options.timeoutMs); let network: VendorNetwork; try { network = createVendorNetwork(request.invocation, request.imageId, Math.min(60_000, remaining())); } catch (error) { diff --git a/agents/adapters/types.ts b/agents/adapters/types.ts index bcbaeaf..01291b9 100644 --- a/agents/adapters/types.ts +++ b/agents/adapters/types.ts @@ -13,6 +13,7 @@ export interface AgentAdapterOptions { readonly timeoutMs?: number; readonly limits?: Partial; } +const MAXIMUM_INVOCATION_MS = 10 * 60_000; /** Convert an absolute wall-clock deadline once, then enforce it with a monotonic clock. */ export function createInvocationBudget(invocation: InvocationInput, maximumMs: number): () => number { @@ -30,3 +31,12 @@ export function createInvocationBudget(invocation: InvocationInput, maximumMs: n return value; }; } + +export function createAdapterInvocationBudget(invocation: InvocationInput, + timeoutMs = MAXIMUM_INVOCATION_MS): () => number { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) + throw new Error('timeoutMs must be a positive integer.'); + if (timeoutMs > MAXIMUM_INVOCATION_MS) + throw new Error('timeoutMs cannot exceed the production ten-minute ceiling.'); + return createInvocationBudget(invocation, timeoutMs); +} diff --git a/test/agent-adapter.test.ts b/test/agent-adapter.test.ts index 97c2b1c..2c0af9f 100644 --- a/test/agent-adapter.test.ts +++ b/test/agent-adapter.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { parseClaudeOutput, startClaudeInvocation } from '../agents/adapters/claude.ts'; import { CODEX_OUTPUT_FILE, startCodexInvocation } from '../agents/adapters/codex.ts'; import { isInvocationActive, OUTPUT_LIMITS, retainSetupCleanup } from '../agents/adapters/supervisor.ts'; -import { createInvocationBudget } from '../agents/adapters/types.ts'; +import { createAdapterInvocationBudget, createInvocationBudget } from '../agents/adapters/types.ts'; import { captureInvocation } from '../agents/contract.ts'; import { createCodexCommand, createPhasePolicy } from '../agents/policy.ts'; @@ -85,4 +85,12 @@ describe('production agent adapters', () => { expect(remaining()).toBeLessThanOrEqual(1_000); } finally { clock.mockRestore(); } }); + + it('applies the configured timeout to the original adapter setup budget', () => { + const invocation = capturedInvocation('configured-budget', Date.now() + 60_000); + const remaining = createAdapterInvocationBudget(invocation, 250); + expect(remaining()).toBeGreaterThan(0); + expect(remaining()).toBeLessThanOrEqual(250); + expect(() => createAdapterInvocationBudget(invocation, 10 * 60_000 + 1)).toThrow('ten-minute ceiling'); + }); }); From 0c91f87a8ce98b761e29a04bd83a9e8e8c82c7e7 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 21:04:52 -0700 Subject: [PATCH 37/44] Bound adapter cleanup and final stderr --- agents/adapters/claude.ts | 10 +++++----- agents/adapters/codex.ts | 10 +++++----- agents/adapters/supervisor.ts | 13 ++++++++----- agents/network/network.ts | 4 ++-- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/agents/adapters/claude.ts b/agents/adapters/claude.ts index 319f6eb..902d3dd 100644 --- a/agents/adapters/claude.ts +++ b/agents/adapters/claude.ts @@ -36,18 +36,18 @@ export function startClaudeInvocation(request: AgentAdapterRequest, decode: (_profile, raw) => parseClaudeOutput(raw) }); } catch (error) { if (error instanceof ProfileCreationCleanupError) { - const retryCleanup = () => { + const retryCleanup = (networkTimeoutMs = 30_000) => { const failures: unknown[] = []; try { error.retryCleanup(); } catch (cleanupError) { failures.push(cleanupError); } - try { removeVendorNetwork(network); } catch (cleanupError) { failures.push(cleanupError); } + try { removeVendorNetwork(network, networkTimeoutMs); } catch (cleanupError) { failures.push(cleanupError); } if (failures.length) throw new AggregateError(failures, 'Adapter setup cleanup did not settle.'); }; - try { retryCleanup(); } - catch (cleanupError) { return retainSetupCleanup(request.invocation, retryCleanup, + try { retryCleanup(Math.min(30_000, remaining())); } + catch (cleanupError) { return retainSetupCleanup(request.invocation, () => retryCleanup(), error.startupError, cleanupError, 'profile and network cleanup'); } throw error.startupError; } - try { removeVendorNetwork(network); } + try { removeVendorNetwork(network, Math.min(30_000, remaining())); } catch (cleanupError) { return retainNetworkCleanup(request.invocation, network, error, cleanupError); } throw error; } diff --git a/agents/adapters/codex.ts b/agents/adapters/codex.ts index 21d9e94..158f8fe 100644 --- a/agents/adapters/codex.ts +++ b/agents/adapters/codex.ts @@ -39,18 +39,18 @@ export function startCodexInvocation(request: AgentAdapterRequest, readCodexOutput(current.name, maximum, timeoutMs, signal) }); } catch (error) { if (error instanceof ProfileCreationCleanupError) { - const retryCleanup = () => { + const retryCleanup = (networkTimeoutMs = 30_000) => { const failures: unknown[] = []; try { error.retryCleanup(); } catch (cleanupError) { failures.push(cleanupError); } - try { removeVendorNetwork(network); } catch (cleanupError) { failures.push(cleanupError); } + try { removeVendorNetwork(network, networkTimeoutMs); } catch (cleanupError) { failures.push(cleanupError); } if (failures.length) throw new AggregateError(failures, 'Adapter setup cleanup did not settle.'); }; - try { retryCleanup(); } - catch (cleanupError) { return retainSetupCleanup(request.invocation, retryCleanup, + try { retryCleanup(Math.min(30_000, remaining())); } + catch (cleanupError) { return retainSetupCleanup(request.invocation, () => retryCleanup(), error.startupError, cleanupError, 'profile and network cleanup'); } throw error.startupError; } - try { removeVendorNetwork(network); } + try { removeVendorNetwork(network, Math.min(30_000, remaining())); } catch (cleanupError) { return retainNetworkCleanup(request.invocation, network, error, cleanupError); } throw error; } diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 8887c71..3cc33fb 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -315,8 +315,8 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super decodeAbort?.abort(); if (!closed) terminate(); }; - const capture = (stream: 'stdout' | 'stderr', value: Buffer | string) => { - if (stopReason || closed) return; + const capture = (stream: 'stdout' | 'stderr', value: Buffer | string, final = false) => { + if (!final && (stopReason || closed)) return; const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); const streamBytes = stream === 'stdout' ? stdoutBytes : stderrBytes; const streamLimit = stream === 'stdout' ? limits.stdoutBytes : limits.stderrBytes; @@ -328,7 +328,10 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super else stderrBytes += retained.length; combinedBytes += retained.length; } - if (chunk.length > available) stop('output-limit'); + if (chunk.length > available) { + if (final) stopReason ??= 'output-limit'; + else stop('output-limit'); + } }; const consumeProtocol = (length: number) => { const available = Math.max(0, Math.min(limits.stderrBytes - stderrBytes, @@ -486,11 +489,11 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super for (const timer of timers) clearTimeout(timer); timers.clear(); if (!stopReason && performance.now() >= deadline) stopReason = 'timeout'; + closed = true; if (protocolBuffer.length) { - if (!protocolLine(protocolBuffer)) capture('stderr', protocolBuffer); + if (!protocolLine(protocolBuffer)) capture('stderr', protocolBuffer, true); protocolBuffer = Buffer.alloc(0); } - closed = true; let finalStdout = Buffer.concat(stdoutChunks, stdoutBytes), finalStderr = Buffer.concat(stderrChunks); let exitCode = code, finalSignal = signal; if (!stopReason && options.decode && !profile.deferredOutput) await decodeOutput(); diff --git a/agents/network/network.ts b/agents/network/network.ts index 10c9ab2..2985e32 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -178,7 +178,7 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string } } -export function removeVendorNetwork(network: VendorNetwork): void { +export function removeVendorNetwork(network: VendorNetwork, timeoutMs = 30_000): void { const identity = identities.get(network); if (!identity) { if (removedNetworks.has(network)) return; @@ -186,7 +186,7 @@ export function removeVendorNetwork(network: VendorNetwork): void { } assertBuiltAgentImage(identity.imageId); const allocationId = identity.allocationId; - const remaining = deadline(30_000), failures: unknown[] = []; + const remaining = deadline(timeoutMs), failures: unknown[] = []; try { remove(['rm', '--force', network.proxyContainer], ['container', 'inspect', network.proxyContainer], remaining, 'vendor proxy', allocationId); } catch (error) { failures.push(error); } try { remove(['network', 'rm', network.name], ['network', 'inspect', network.name], From da1976e52b3484411807b6d53f7a9de28b2aad42 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 21:16:52 -0700 Subject: [PATCH 38/44] Keep decoder settlement timers alive --- agents/adapters/supervisor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 3cc33fb..2f0edca 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -365,7 +365,6 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super stop('timeout'); reject(new CaptureDeadlineError('Adapter output capture exceeded the invocation deadline.')); }, budget); - decodeTimer.unref(); }); let decoded: DecodedOutput; try { decoded = await Promise.race([operation, timeout, aborted]); } @@ -374,7 +373,6 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super let graceTimer: ReturnType | undefined; const grace = new Promise(resolve => { graceTimer = setTimeout(resolve, CAPTURE_ABORT_GRACE_MS); - graceTimer.unref(); }); await Promise.race([operation.then(() => undefined, () => undefined), grace]); if (graceTimer) clearTimeout(graceTimer); From ace826cdd09c4f9289c1a80a6a690e92d88f1092 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 21:29:22 -0700 Subject: [PATCH 39/44] Retain recovery profile ownership --- agents/adapters/supervisor.ts | 7 +++++-- test/agent-supervisor.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 2f0edca..29e84e9 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -78,6 +78,7 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string, regis timer = undefined; complete = true; if (active.get(invocation.attemptId) === handle) active.delete(invocation.attemptId); + activeProfiles.delete(profile); cleanupRecoveries.delete(handle); resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', @@ -91,6 +92,7 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string, regis }; handle = Object.freeze({ attemptId: invocation.attemptId, settled, cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); + activeProfiles.add(profile); if (register) active.set(invocation.attemptId, handle); else cleanupRecoveries.add(handle); schedule(); @@ -399,8 +401,9 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super decodedOutput = decoded; } catch (error) { const message = error instanceof Error ? error.message : String(error); - const reason = error instanceof OutputLimitError || /exceeds its capture limit/i.test(message) - ? 'output-limit' : error instanceof CaptureDeadlineError ? 'timeout' : 'capture-failure'; + const reason: StopReason = stopReason ?? (performance.now() >= deadline ? 'timeout' + : error instanceof OutputLimitError || /exceeds its capture limit/i.test(message) + ? 'output-limit' : error instanceof CaptureDeadlineError ? 'timeout' : 'capture-failure'); failureDetail ??= message; if (closed) stopReason ??= reason; else stop(reason); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 35f76e3..c03f0cf 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -229,6 +229,18 @@ describe('container invocation supervisor', () => { expect(result.stdout).not.toContain('must-not-publish'); }, 15_000); + it('classifies a decoder failure after the monotonic deadline as timeout', async () => { + const result = await startProfileInvocation(profile(fixture(), 'finite-output', 'decode-fails-late', 30_000), { + timeoutMs: 3_000, + decode: (_current, _raw, _maximum, timeoutMs) => { + const end = performance.now() + timeoutMs + 50; + while (performance.now() < end) { /* deliberately block the timer queue */ } + throw new Error('late decoder failure'); + }, + }).settled; + expect(result.stopReason).toBe('timeout'); + }, 15_000); + it('validates and decodes provider output even when the process exits nonzero', async () => { const result = await startProfileInvocation(profile(fixture(), 'nonzero-output', 'nonzero-decode'), { decode: (_current, raw) => ({ text: `decoded:${raw.toString('utf8')}` }), From 0a0ce547b3ec88d4c91a8acd2c8166b771f5f436 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 21:42:15 -0700 Subject: [PATCH 40/44] Revalidate container at launch boundary --- agents/adapters/supervisor.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 29e84e9..3bc1f41 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -242,7 +242,6 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super }; try { createValidatedContainer(profile, remaining(), options.secrets ?? {}); - validateContainer(profile.name, profile, remaining()); } catch (error) { try { disposeValidatedContainer(profile); } catch (cleanupError) { @@ -260,6 +259,15 @@ export function startProfileInvocation(profile: ContainerProfile, options: Super let decodeAbort: AbortController | undefined; let protocolToken: string | undefined, protocolStarted = false, protocolReady = false; let protocolBuffer = Buffer.alloc(0); + try { validateContainer(profile.name, profile, remaining()); } + catch (error) { + try { disposeValidatedContainer(profile); } + catch (cleanupError) { + const detail = `Final container validation failed and cleanup remains unsettled: ${String(error)}; ${String(cleanupError)}`; + return retainCleanupOwnership(profile, detail); + } + throw error; + } const child = spawn('docker', ['start', '--attach', profile.name], { env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], }); From 02ce3e11ee972fab5473fc33c34595939bdbd286 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 21:58:32 -0700 Subject: [PATCH 41/44] Validate profile capability before cleanup --- agents/adapters/supervisor.ts | 3 ++- agents/container/profile.ts | 5 +++++ test/agent-supervisor.test.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 3bc1f41..7380502 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -2,7 +2,7 @@ import { execFile, spawn, type ChildProcess } from 'node:child_process'; import type { InvocationHandle, InvocationInput, InvocationResult, StopReason } from '../contract.ts'; import { assertPhasePolicy } from '../policy.ts'; import { createValidatedContainer, disposeValidatedContainer, validateContainer } from '../container/run.ts'; -import type { ContainerProfile } from '../container/profile.ts'; +import { assertContainerProfileAuthenticity, type ContainerProfile } from '../container/profile.ts'; import { removeVendorNetwork, type VendorNetwork } from '../network/network.ts'; export const OUTPUT_LIMITS = Object.freeze({ @@ -202,6 +202,7 @@ export function isInvocationActive(attemptId: string): boolean { } export function startProfileInvocation(profile: ContainerProfile, options: SupervisorOptions = {}): InvocationHandle { + assertContainerProfileAuthenticity(profile); const invocation = assertPhasePolicy(profile.policy); const rejectWithCleanup = (error: unknown, register = true): InvocationHandle => { try { disposeValidatedContainer(profile); } diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 3689899..3da225a 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -123,6 +123,11 @@ const captureInput = (directory: string): InputCapture => { return Object.freeze({ inputDirectory: canonical, schema, content: captured.content }); }; +/** Prove that a profile object is the exact capability issued by this module. */ +export function assertContainerProfileAuthenticity(profile: ContainerProfile): void { + if (!identities.has(profile)) throw new Error('Container profile was not created by the trusted profile builder.'); +} + /** Internal authenticity and host-file revalidation used at every launch boundary. */ export function assertContainerProfile(profile: ContainerProfile, timeoutMs = 30_000): void { const expected = identities.get(profile); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index c03f0cf..7e6cdae 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -152,6 +152,17 @@ describe('container invocation supervisor', () => { expect((await first.settled).stopReason).toBe('shutdown'); }, 60_000); + it('rejects a cloned profile without disposing the authentic active container', async () => { + const current = profile(fixture(), 'ignore-term', 'cloned-profile'); + const first = startProfileInvocation(current, { timeoutMs: 30_000 }); + const clone = Object.freeze({ ...current }); + expect(() => startProfileInvocation(clone)).toThrow('not created by the trusted profile builder'); + expect(isInvocationActive('cloned-profile')).toBe(true); + expect(spawnSync('docker', ['container', 'inspect', current.name]).status).toBe(0); + first.cancel('shutdown'); + expect((await first.settled).stopReason).toBe('shutdown'); + }, 60_000); + it('records decoder failure without publishing a successful result', async () => { const handle = startProfileInvocation(profile(fixture(), 'finite-output', 'capture-failure'), { decode: () => { throw new Error('simulated capture failure'); }, From 158e07d46ec7eed556ba1959f799a0df8ed7166b Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 22:14:43 -0700 Subject: [PATCH 42/44] Preserve cleanup cancellation reasons --- agents/adapters/supervisor.ts | 24 ++++++++++++++++++------ test/agent-supervisor.test.ts | 20 +++++++++++++++++++- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/agents/adapters/supervisor.ts b/agents/adapters/supervisor.ts index 7380502..dfc05a3 100644 --- a/agents/adapters/supervisor.ts +++ b/agents/adapters/supervisor.ts @@ -62,6 +62,7 @@ const diagnosticFor = (reason: StopReason, detail?: string) => Buffer.from( const retainCleanupOwnership = (profile: ContainerProfile, detail: string, register = true): InvocationHandle => { const invocation = assertPhasePolicy(profile.policy); let resolveSettled!: (result: InvocationResult) => void, cleaning = false, complete = false; + let cancelReason: StopReason | undefined; let handle!: InvocationHandle; let timer: ReturnType | undefined; const settled = new Promise(resolve => { resolveSettled = resolve; }); @@ -81,8 +82,8 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string, regis activeProfiles.delete(profile); cleanupRecoveries.delete(handle); resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, - exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', - stderr: diagnosticFor('capture-failure', detail).toString('utf8') })); + exitCode: null, signal: null, stopReason: cancelReason ?? 'capture-failure', stdout: '', + stderr: diagnosticFor(cancelReason ?? 'capture-failure', detail).toString('utf8') })); } catch { cleaning = false; schedule(); @@ -91,7 +92,12 @@ const retainCleanupOwnership = (profile: ContainerProfile, detail: string, regis cleaning = false; }; handle = Object.freeze({ attemptId: invocation.attemptId, settled, - cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); + cancel: (reason: StopReason) => { + cancelReason ??= reason; + if (timer) clearTimeout(timer); + timer = undefined; + retry(); + } }); activeProfiles.add(profile); if (register) active.set(invocation.attemptId, handle); else cleanupRecoveries.add(handle); @@ -111,6 +117,7 @@ export function retainSetupCleanup(invocation: InvocationInput, retryCleanup: () startupError: unknown, cleanupError: unknown, kind = 'setup cleanup'): InvocationHandle { const register = !ownsAttempt(invocation.attemptId); let resolveSettled!: (result: InvocationResult) => void, cleaning = false, complete = false; + let cancelReason: StopReason | undefined; let handle!: InvocationHandle; let timer: ReturnType | undefined; const settled = new Promise(resolve => { resolveSettled = resolve; }); @@ -126,8 +133,8 @@ export function retainSetupCleanup(invocation: InvocationInput, retryCleanup: () if (active.get(invocation.attemptId) === handle) active.delete(invocation.attemptId); cleanupRecoveries.delete(handle); resolveSettled(Object.freeze({ attemptId: invocation.attemptId, context: invocation.context, - exitCode: null, signal: null, stopReason: 'capture-failure', stdout: '', - stderr: diagnosticFor('capture-failure', detail).toString('utf8') })); + exitCode: null, signal: null, stopReason: cancelReason ?? 'capture-failure', stdout: '', + stderr: diagnosticFor(cancelReason ?? 'capture-failure', detail).toString('utf8') })); } catch { cleaning = false; if (!timer) { @@ -138,7 +145,12 @@ export function retainSetupCleanup(invocation: InvocationInput, retryCleanup: () cleaning = false; }; handle = Object.freeze({ attemptId: invocation.attemptId, settled, - cancel: () => { if (timer) clearTimeout(timer); timer = undefined; retry(); } }); + cancel: (reason: StopReason) => { + cancelReason ??= reason; + if (timer) clearTimeout(timer); + timer = undefined; + retry(); + } }); if (register) active.set(invocation.attemptId, handle); else cleanupRecoveries.add(handle); timer = setTimeout(() => { timer = undefined; retry(); }, 1_000); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 7e6cdae..0211f21 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -5,7 +5,8 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { startClaudeInvocation } from '../agents/adapters/claude.ts'; import { readCodexOutput, startCodexInvocation } from '../agents/adapters/codex.ts'; -import { isInvocationActive, readBoundedContainerFile, startProfileInvocation } from '../agents/adapters/supervisor.ts'; +import { isInvocationActive, readBoundedContainerFile, retainSetupCleanup, + startProfileInvocation } from '../agents/adapters/supervisor.ts'; import { captureInvocation, type InvocationInput } from '../agents/contract.ts'; import { buildAgentImage } from '../agents/container/image.ts'; import { createContainerProfile, disposeContainerProfile, type ContainerProfile } from '../agents/container/profile.ts'; @@ -62,6 +63,23 @@ afterAll(() => { }, 3 * 60_000); describe('container invocation supervisor', () => { + it('preserves the first cancellation reason while retained setup cleanup settles', async () => { + const data = fixture(), captured = invocation(data, 'cancel-setup-cleanup'); + let attempts = 0; + const handle = retainSetupCleanup(captured, () => { + attempts += 1; + if (attempts === 1) return; + throw new Error('unexpected repeated cleanup'); + }, new Error('startup failed'), new Error('cleanup failed')); + handle.cancel('shutdown'); + handle.cancel('cancelled'); + const result = await handle.settled; + expect(result.stopReason).toBe('shutdown'); + expect(result.stderr).toContain('[codeboost: shutdown:'); + expect(attempts).toBe(1); + expect(isInvocationActive('cancel-setup-cleanup')).toBe(false); + }); + it('captures finite output and releases ownership only after cleanup', async () => { const current = profile(fixture(), 'finite-output', 'finite'); const handle = startProfileInvocation(current); From 5506d655518a205230eb3f4dec0900bf0958ed30 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 22:21:06 -0700 Subject: [PATCH 43/44] Update cleanup cancellation regression --- test/agent-adapter.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/agent-adapter.test.ts b/test/agent-adapter.test.ts index 2c0af9f..0d727c4 100644 --- a/test/agent-adapter.test.ts +++ b/test/agent-adapter.test.ts @@ -70,7 +70,8 @@ describe('production agent adapters', () => { releaseSecond = true; second.cancel('cancelled'); const result = await second.settled; - expect(result.stopReason).toBe('capture-failure'); + expect(result.stopReason).toBe('cancelled'); + expect(result.stderr).toContain('[codeboost: cancelled:'); expect(result.stderr).toContain('setup cleanup remains unsettled'); expect(isInvocationActive(invocation.attemptId)).toBe(false); }); From a371bc77cd9b809489a50eb5b28ca17c51d87315 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 22:34:22 -0700 Subject: [PATCH 44/44] Authenticate profiles at disposal boundary --- agents/container/run.ts | 4 +++- test/agent-supervisor.test.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/agents/container/run.ts b/agents/container/run.ts index 6990853..ae90d3d 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -1,6 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { realpathSync } from 'node:fs'; -import { assertContainerProfile, disposeContainerProfile, type ContainerProfile } from './profile.ts'; +import { assertContainerProfile, assertContainerProfileAuthenticity, disposeContainerProfile, + type ContainerProfile } from './profile.ts'; import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; import { taskFilesystemAllocationId } from './storage.ts'; export { prepareTaskFilesystems, removeTaskFilesystems } from './storage.ts'; @@ -74,6 +75,7 @@ const removeContainerOrThrow = (profile: ContainerProfile) => { /** Remove a validated invocation container, then its profile-owned staging and network resources. */ export function disposeValidatedContainer(profile: ContainerProfile): void { + assertContainerProfileAuthenticity(profile); removeContainerOrThrow(profile); } diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 0211f21..f69b21a 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -10,7 +10,7 @@ import { isInvocationActive, readBoundedContainerFile, retainSetupCleanup, import { captureInvocation, type InvocationInput } from '../agents/contract.ts'; import { buildAgentImage } from '../agents/container/image.ts'; import { createContainerProfile, disposeContainerProfile, type ContainerProfile } from '../agents/container/profile.ts'; -import { prepareTaskFilesystems, removeTaskFilesystems } from '../agents/container/run.ts'; +import { disposeValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems } from '../agents/container/run.ts'; import { createVendorNetwork } from '../agents/network/network.ts'; import { createIsolationProbeCommand, createPhasePolicy, type IsolationProbe } from '../agents/policy.ts'; import { createTaskClone } from '../git/clone.ts'; @@ -174,6 +174,7 @@ describe('container invocation supervisor', () => { const current = profile(fixture(), 'ignore-term', 'cloned-profile'); const first = startProfileInvocation(current, { timeoutMs: 30_000 }); const clone = Object.freeze({ ...current }); + expect(() => disposeValidatedContainer(clone)).toThrow('not created by the trusted profile builder'); expect(() => startProfileInvocation(clone)).toThrow('not created by the trusted profile builder'); expect(isInvocationActive('cloned-profile')).toBe(true); expect(spawnSync('docker', ['container', 'inspect', current.name]).status).toBe(0);