diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index f709abf..395e87e 100644 --- a/.github/workflows/agent-isolation.yml +++ b/.github/workflows/agent-isolation.yml @@ -28,4 +28,5 @@ 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 + # The Docker suites share one image tag and daemon, so run test files one at a time. + - 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-proxy.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab3d4c5..330b0dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: cache: npm - run: npm ci --ignore-scripts - run: npm run typecheck - - run: npm test + # The Docker agent suites run one file at a time in the Agent isolation workflow; running them here + # would put them in parallel against the same image tag and daemon. + - run: npm test -- --exclude test/agent-container.test.ts --exclude test/agent-network.test.ts - run: npx playwright install --with-deps chromium - run: npm run test:browser 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 41a85ed..02c5f26 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -6,6 +6,8 @@ import { join } from 'node:path'; import { assertCapturedInvocation, type InvocationInput, type Phase } from '../contract.ts'; import { assertBuiltAgentImage } from './image.ts'; import { assertTaskFilesystems, type TaskFilesystems } from './storage.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; readonly args: readonly string[]; @@ -17,15 +19,19 @@ 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; readonly filesystems: TaskFilesystems; readonly inputDirectory: string; - readonly command: readonly string[]; + readonly command: AgentCommand; readonly imageId: string; readonly codexAuthFile?: string; readonly claudeToken?: string; + readonly network: VendorNetwork; + readonly policy: PhasePolicy; } interface FileIdentity { @@ -40,10 +46,12 @@ 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 deadline: number } + readonly clone: InvocationInput['clone']; readonly deadline: number; readonly network: VendorNetwork; + readonly policy: PhasePolicy; readonly invocation: InvocationInput } 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); @@ -105,10 +113,13 @@ 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); + // Every caller, including those using the default budget, is bounded by the invocation deadline. + assertVendorNetwork(expected.network, expected.invocation, profile.name, profileTimeout(profile, timeoutMs)); + 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.'); @@ -118,6 +129,10 @@ export function assertContainerProfile(profile: ContainerProfile): void { } } +export function isContainerProfileAuthentic(profile: ContainerProfile): boolean { + return identities.has(profile); +} + /** Clamp a Docker budget to the captured invocation deadline, which no launch may outlive. */ export function profileTimeout(profile: ContainerProfile, timeoutMs: number, now = Date.now()): number { const expected = identities.get(profile); @@ -131,7 +146,10 @@ export function profileTimeout(profile: ContainerProfile, timeoutMs: number, now 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); } @@ -150,26 +168,32 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const { invocation, filesystems } = options; // Phase, vendor and deadline drive mount modes and credentials, so they must come from a captured request. assertCapturedInvocation(invocation); - 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); - 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)) - 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.'); - // Read through one no-follow descriptor so the path cannot be swapped between check and open. - const sourceAuth = options.codexAuthFile ? readCapturedFile(options.codexAuthFile, 'Codex auth') : undefined; + const invocationLeft = Math.floor(invocation.deadline - Date.now()); + if (invocationLeft < 1) throw new Error('Invocation deadline has passed.'); + assertVendorNetwork(options.network, invocation, undefined, Math.min(30_000, invocationLeft)); + if (claimedNetworks.has(options.network)) throw new Error('Vendor network already belongs to another container profile.'); + // Own the network from here on, so any later failure removes it rather than leaking it. + claimedNetworks.add(options.network); const cleanupDirectories: string[] = []; let codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined; try { + assertPhasePolicy(options.policy, invocation); + 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.'); + 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.'); + // Read through one no-follow descriptor so the path cannot be swapped between check and open. + const sourceAuth = options.codexAuthFile ? readCapturedFile(options.codexAuthFile, 'Codex auth') : undefined; const inputDirectory = mkdtempSync(join(tmpdir(), 'codeboost-input-')); cleanupDirectories.push(inputDirectory); writeFileSync(join(inputDirectory, 'schema.json'), sourceInput.content, @@ -191,9 +215,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', '--cpus=1', '--shm-size=16m', '--ipc=private', '--cgroupns=private', - '--network=none', '--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}`, + '--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', @@ -207,20 +234,22 @@ 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 }); + 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, - deadline: invocation.deadline })); + deadline: invocation.deadline, network: options.network, policy: options.policy, invocation })); return profile; } catch (error) { - try { removeOwnedDirectories(cleanupDirectories); } - catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Profile creation and cleanup both failed.'); } + const failures: unknown[] = []; + try { removeOwnedDirectories(cleanupDirectories); } catch (cleanupError) { failures.push(cleanupError); } + try { removeVendorNetwork(options.network); } catch (cleanupError) { failures.push(cleanupError); } + if (failures.length) throw new AggregateError([error, ...failures], 'Profile creation and cleanup both failed.'); throw error; } } diff --git a/agents/container/run.ts b/agents/container/run.ts index b26847a..e9d7b7f 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, profileTimeout, type ContainerProfile } from './profile.ts'; +import { assertContainerProfile, disposeContainerProfile, isContainerProfileAuthentic, profileTimeout, + 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'; @@ -48,6 +49,9 @@ const canonicalDockerBindSource = (source: string) => { const CREATE_SETTLE_MS = 10_000; const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); const removeContainerOrThrow = (profile: ContainerProfile, createUnsettled = false) => { + // Destructive cleanup acts only for the builder-registered profile; a copy's name and label are not a capability. + if (!isContainerProfileAuthentic(profile)) + throw new Error('Container profile was not created by the trusted profile builder.'); const remaining = createDeadline(30_000 + (createUnsettled ? CREATE_SETTLE_MS : 0)); const settleBy = performance.now() + (createUnsettled ? CREATE_SETTLE_MS : 0); let before: ReturnType; @@ -101,14 +105,17 @@ type Inspect = { StorageOpt?: Record | null; CgroupParent: string; RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null; Runtime: 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[]; + 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; Ports: Record }; }; /** 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 @@ -131,7 +138,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 || !exactSecurityOptions(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 @@ -146,6 +153,14 @@ export function validateContainer(container: string, profile: ContainerProfile, || !['', 'no'].includes(host.RestartPolicy?.Name ?? '') || (host.RestartPolicy?.MaximumRetryCount ?? 0) !== 0 || host.Runtime !== 'runc') 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 ?? {}; const expectedTmpfs = new Map([ ['/tmp', ['rw', 'nosuid', 'nodev', 'size=33554432', 'nr_inodes=4096', 'mode=1777']], @@ -223,7 +238,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') @@ -234,14 +250,17 @@ 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')) 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(); } @@ -251,7 +270,7 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs = let createUnsettled = false; try { validateSecrets(profile, secrets); - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); const createTimeout = remaining(); createUnsettled = true; try { docker(profile.args, { timeoutMs: createTimeout, secrets }); } @@ -262,7 +281,7 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs = } createUnsettled = false; validateContainer(profile.name, profile, remaining()); - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); remaining(); return profile.name; } catch (error) { @@ -272,14 +291,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(profileTimeout(profile, timeoutMs)); - const container = createValidatedContainer(profile, remaining(), secrets); let failure: unknown; try { - assertContainerProfile(profile); - 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; } @@ -292,3 +311,17 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, } } } + +export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, + secrets: Readonly> = {}): string { + const remaining = createDeadline(profileTimeout(profile, 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 new file mode 100644 index 0000000..56c451a --- /dev/null +++ b/agents/network/network.ts @@ -0,0 +1,235 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { assertCapturedInvocation, 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; readonly invocation: InvocationInput; + readonly subnet: string; readonly proxyIp: string; + /** Daemon object IDs captured at creation; a same-named replacement has a different ID. */ + readonly networkId: string; readonly proxyId: 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.'); + 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)|network .* not found)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); +/** How long a network or proxy whose create client was killed may still materialize in the daemon. */ +const CREATE_SETTLE_MS = 10_000; +const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +const remove = (args: readonly string[], inspect: readonly string[], remaining: () => number, kind: string, + allocationId: string, settleBy = 0) => { + let before: ReturnType; + for (;;) { + before = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (before.status === 0) break; + if (!absent(before)) throw new Error(`Failed to establish ownership of ${kind}.`); + // A killed create may still land; only absence after the settle window counts. + if (performance.now() >= settleBy) return; + sleep(250); + } + const inspected = JSON.parse(String(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}.`); +}; + +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); + // Inspect by the captured IDs, so a removed-and-recreated network or proxy cannot stand in for the original. + const inspectAllocated = (args: readonly string[]) => { + try { return docker(args, remaining()); } + catch (cause) { throw new Error('Vendor network or proxy changed after allocation.', { cause }); } + }; + const inspect = JSON.parse(inspectAllocated(['container', 'inspect', identity.proxyId]))[0] as + { Id?: string; 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; + NetworkMode?: string; PidMode?: string; IpcMode?: string; UTSMode?: string; UsernsMode?: string; + CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null; + Dns?: string[]; DnsOptions?: string[]; DnsSearch?: string[]; ExtraHosts?: string[] | null; + PortBindings?: Record | null; PublishAllPorts?: boolean; Runtime?: string; + RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null }; + 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(inspectAllocated(['network', 'inspect', identity.networkId]))[0] as + { Id?: string; Name?: string; 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?.Id !== identity.proxyId || inspectedNetwork?.Id !== identity.networkId + || inspectedNetwork.Name !== network.name || !Object.keys(inspectedNetwork.Containers ?? {}).includes(identity.proxyId) + || !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 !== 2 + || !inspect.HostConfig.SecurityOpt.some(option => ['no-new-privileges', 'no-new-privileges:true'].includes(option)) + || !inspect.HostConfig.SecurityOpt.includes('seccomp=builtin') + || inspect.HostConfig.Runtime !== 'runc' + || !['', 'no'].includes(inspect.HostConfig.RestartPolicy?.Name ?? '') + || (inspect.HostConfig.RestartPolicy?.MaximumRetryCount ?? 0) !== 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 + || (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']) + || 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 + || !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, + timeoutMs = 60_000): VendorNetwork { + assertCapturedInvocation(invocation); + assertBuiltAgentImage(imageId); + const vendor = invocation.vendor; + // Setup runs inside the caller's budget minus a cleanup reserve, so failure cleanup cannot overrun timeoutMs. + // No allocation may outlive the invocation it serves. + const invocationLeft = Math.floor(invocation.deadline - Date.now()); + if (invocationLeft < 1) throw new Error('Invocation deadline has passed.'); + timeoutMs = Math.min(timeoutMs, invocationLeft); + const overall = deadline(timeoutMs), cleanupReserve = Math.min(10_000, Math.floor(timeoutMs / 3)); + const remaining = deadline(Math.max(1, timeoutMs - cleanupReserve)), 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; + // IDs of the objects this call created; cleanup targets these, and names only for a create whose ID never returned. + let networkId: string | undefined, proxyId: string | undefined; + const unsettled = new Set(); + const createdId = (value: string, kind: string) => { + if (!/^[0-9a-f]{64}$/.test(value)) throw new Error(`Docker did not return the created ${kind} ID.`); + return value; + }; + // Run one create step; a client killed by its deadline leaves the daemon outcome for `object` unknown. + const create = (object: string, args: readonly string[]) => { + const timeout = remaining(); + try { return docker(args, timeout); } + catch (error) { + if (typeof (error as { status?: unknown }).status !== 'number') unsettled.add(object); + throw error; + } + }; + try { + networkPlanned = true; + networkId = createdId(create(name, ['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, + '--label', `io.codeboost.egress=${allocationId}`, name]), 'network'); + proxyPlanned = true; + proxyId = createdId(create(proxyContainer, ['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--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']), 'proxy'); + docker(['network', 'connect', 'bridge', proxyId], remaining()); + docker(['exec', proxyId, '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 proxyInspect = JSON.parse(docker(['container', 'inspect', proxyId], 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, networkId, proxyId })); + validateVendorNetwork(network, invocation, undefined, remaining); + remaining(); + return network; + } catch (error) { + const failures: unknown[] = []; + // Killed creates get a settle window, but only inside the cleanup reserve of the caller's budget. + let reserveLeft = 0; + try { reserveLeft = overall(); } catch { /* the overall budget is spent */ } + const settleBy = (object: string) => unsettled.has(object) + ? performance.now() + Math.min(CREATE_SETTLE_MS, reserveLeft) : 0; + const cleanupBudget = overall; + const proxyTarget = proxyId ?? proxyContainer, networkTarget = networkId ?? name; + if (proxyPlanned) try { remove(['rm', '--force', proxyTarget], ['container', 'inspect', proxyTarget], + cleanupBudget, 'vendor proxy', allocationId, proxyId ? 0 : settleBy(proxyContainer)); } + catch (cleanupError) { failures.push(cleanupError); } + if (networkPlanned) try { remove(['network', 'rm', networkTarget], ['network', 'inspect', networkTarget], + cleanupBudget, 'vendor network', allocationId, networkId ? 0 : settleBy(name)); } + 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 { + const identity = identities.get(network); + 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[] = []; + // Remove by the captured IDs; a same-named replacement is not ours to delete and keeps the network busy. + try { remove(['rm', '--force', identity.proxyId], ['container', 'inspect', identity.proxyId], + remaining, 'vendor proxy', allocationId); } catch (error) { failures.push(error); } + try { remove(['network', 'rm', identity.networkId], ['network', 'inspect', identity.networkId], + 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/agents/network/proxy.mjs b/agents/network/proxy.mjs new file mode 100644 index 0000000..a3a8975 --- /dev/null +++ b/agents/network/proxy.mjs @@ -0,0 +1,56 @@ +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.'); +// Ports are fixed in production; the overrides exist so tests can run the proxy against a local upstream. +const listenPort = Number(process.env.CODEBOOST_PROXY_PORT ?? 3128); +const upstreamPort = Number(process.env.CODEBOOST_UPSTREAM_PORT ?? 443); +const connectLine = new RegExp(`^CONNECT ([a-z0-9.-]+):${upstreamPort} HTTP\\/1\\.[01]$`); + +const MAX_HEADER_BYTES = 8192; + +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); + const receive = chunk => { + // Between reads `request` holds at most an unfinished 8 KiB header, so memory stays within the header limit plus + // one socket read however much a client streams. Tunnel bytes in the same read as the header may follow it. + request = Buffer.concat([request, chunk], request.length + chunk.length); + const boundary = request.subarray(0, MAX_HEADER_BYTES).indexOf('\r\n\r\n'); + if (boundary < 0 && request.length >= MAX_HEADER_BYTES) { + client.off('data', receive); + refuse(client, '431 Request Header Fields Too Large'); + return; + } + if (boundary < 0) return; + // Stop reading until the tunnel is piped, so bytes sent after CONNECT stay buffered instead of being dropped. + client.off('data', receive); + client.pause(); + const line = request.subarray(0, request.indexOf('\r\n')).toString('ascii'); + const host = connectLine.exec(line)?.[1]; + if (!host || !allowed.has(host)) { + refuse(client); + return; + } + let established = false; + const upstream = connect({ host, port: upstreamPort }); + upstream.setTimeout(300_000, () => upstream.destroy()); + upstream.once('connect', () => { + established = true; + 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); + }); + // Before the tunnel exists the client can still read an HTTP status; inside it, only a reset is safe. + upstream.once('error', () => { if (established) client.destroy(); else refuse(client, '502 Bad Gateway'); }); + client.once('error', () => upstream.destroy()); + client.once('close', () => upstream.destroy()); + }; + client.on('data', receive); + client.once('error', () => undefined); +}).listen(listenPort, '0.0.0.0'); diff --git a/agents/policy.ts b/agents/policy.ts new file mode 100644 index 0000000..17c6349 --- /dev/null +++ b/agents/policy.ts @@ -0,0 +1,117 @@ +import type { InvocationInput, Phase } from './contract.ts'; +import { assertCapturedInvocation, 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; +} +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, Object.freeze({ policy, vendor: assertPhasePolicy(policy).vendor })); + return value; +}; + +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; +} + +export function createPhasePolicy(invocation: InvocationInput): PhasePolicy { + // Tools and the command allowlist come from the phase, so only a captured request may define them. + assertCapturedInvocation(invocation); + 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): AgentCommand { + if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.'); + 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'; + // `--` ends option parsing, so a prompt beginning with `-` stays prompt data. + return command(policy, ['claude', '--print', '--output-format', 'json', '--restricted', '--strict-mcp-config', + '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--no-chrome', '--permission-prompts', 'none', + '--permission-mode', writable ? 'acceptEdits' : 'plan', '--tools', allowed, '--allowedTools', allowed, + '--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir', '/run/codeboost-input', '--', prompt]); +} + +export function codexBaseArguments(policy: PhasePolicy): readonly string[] { + 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']); +} + +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'; + // `--` ends option parsing, so a prompt beginning with `-` stays prompt data. + 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 2478dcc..dd2295f 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -3,19 +3,23 @@ import { randomBytes, randomUUID } from 'node:crypto'; import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, 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'; -import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, +import { assertContainerProfile, createContainerProfile, disposeContainerProfile } from '../agents/container/profile.ts'; +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'; +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 = ''; +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, { @@ -52,25 +56,40 @@ function invocation(clone: ReturnType, phase: Phase, ven 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 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: IsolationProbe | ((policy: ReturnType) => AgentCommand), options: { + vendor?: 'codex' | 'claude'; authProbe?: boolean; codexAuthFile?: string; claudeToken?: string; deadlineMs?: number; } = {}) { const vendor = options.vendor ?? 'codex'; - const base = createContainerProfile({ invocation: invocation(data.clone, phase, vendor), filesystems: data.filesystems, - inputDirectory: data.input, command, - imageId, + const captured = invocation(data.clone, phase, vendor, options.deadlineMs); + 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); return base; } -beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000); +beforeAll(() => { + imageId = buildAgentImage(); +}, 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 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 }); @@ -78,20 +97,24 @@ 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(); + 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'; 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); @@ -100,62 +123,44 @@ describe('real Docker agent isolation', () => { const data = fixture({ historyBytes: 4 * 1024 * 1024, limits: { workBytes: 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, } }); - expect(runContainer(profile(data, 'review', ['sh', '-c', - 'set -eu; test ! -e /work/history.bin; git cat-file -e HEAD~1:history.bin; cat /work/file.txt']))).toBe('trusted'); + expect(runContainer(profile(data, 'execute', 'metadata'))).toBe('metadata-safe'); }, 60_000); it('accepts byte limits that tmpfs rounds up to a whole page', () => { const data = fixture({ limits: { workBytes: 16 * 1024 * 1024 + 1, workInodes: 512, metadataBytes: 16 * 1024 * 1024 + 1, metadataInodes: 512, } }); - expect(runContainer(profile(data, 'execute', ['sh', '-c', 'printf rounded']))).toBe('rounded'); + expect(runContainer(profile(data, 'execute', 'noop'))).toBe(''); }, 60_000); it('requests private IPC and cgroup namespaces instead of relying on daemon defaults', () => { - const args = profile(fixture(), 'planning', ['true']).args; + const args = profile(fixture(), 'planning', 'noop').args; expect(args).toContain('--ipc=private'); expect(args).toContain('--cgroupns=private'); }, 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); @@ -167,27 +172,27 @@ 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'), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'codex')), + filesystems: data.filesystems, inputDirectory: data.input, 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'), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, - claudeToken: 'serialization-sentinel' }); + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'claude')), + filesystems: data.filesystems, inputDirectory: data.input, imageId })).toThrow('OAuth'); + const claudeProfile = createContainerProfile({ ...governed(invocation(data.clone, 'planning', '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'); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), - 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, + 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, 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'); + }, 60_000); 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)]; @@ -205,20 +210,19 @@ 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({ invocation: invocation(data.clone, 'planning'), - filesystems: { ...data.filesystems }, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), + filesystems: { ...data.filesystems }, inputDirectory: data.input, 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, + expect(() => createContainerProfile({ ...governed(invocation(other.clone, 'planning')), + filesystems: data.filesystems, inputDirectory: other.input, codexAuthFile: other.fakeAuth, imageId })).toThrow('do not belong to the invocation clone'); writeFileSync(data.fakeAuth, '{"changed":true}'); @@ -236,7 +240,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)]; @@ -249,7 +253,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/); @@ -259,10 +264,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, codexAuthFile: data.fakeAuth, imageId }); + const duplicate = createContainerProfile({ ...governed(captured), 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('Container creation failed and cleanup did not settle.'); @@ -273,7 +278,7 @@ describe('real Docker agent isolation', () => { }, 60_000); it('retains credentials when a killed create cannot be proven absent', () => { - const data = fixture(), unsettled = profile(data, 'planning', ['true']); + const data = fixture(), unsettled = profile(data, 'planning', 'noop'); const shim = join(data.root, 'docker-shim'); mkdirSync(shim); const realDocker = execFileSync('sh', ['-c', 'command -v docker'], { encoding: 'utf8' }).trim(); // The create client hangs until its deadline kills it, so the daemon outcome stays unknown. @@ -298,7 +303,7 @@ describe('real Docker agent isolation', () => { }, 60_000); it('rejects a container that relies on the daemon default seccomp profile', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); docker(...valid.args.filter(arg => arg !== '--security-opt=seccomp=builtin')); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); docker('rm', '--force', valid.name); containers.delete(valid.name); @@ -323,23 +328,24 @@ describe('real Docker agent isolation', () => { const realDocker = execFileSync('sh', ['-c', 'command -v docker'], { encoding: 'utf8' }).trim(); // The keeper's run client hangs until killed, and the real run lands in the daemon afterwards. writeFileSync(join(shim, 'docker'), ['#!/bin/sh', - `if [ "$1" = run ] && [ "$2" = --detach ]; then ( sleep 4; exec '${realDocker}' "$@" ) >/dev/null 2>&1 /dev/null 2>&1 prepareTaskFilesystems(clone, { workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, - }, imageId, 2_000)).toThrow(); + // A budget that tolerates a loaded daemon; the keeper still lands after its client is killed at ~8 s. + }, imageId, 8_000)).toThrow(); } finally { process.env.PATH = path; } - execFileSync('sleep', ['6']); + execFileSync('sleep', ['3']); const orphans = [...keepers()].filter(id => !before.has(id)); for (const id of orphans) docker('rm', '--force', id); expect(orphans).toEqual([]); }, 60_000); it('rejects a task keeper whose restart policy was changed', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); docker('update', '--restart=always', data.filesystems.keeper); try { docker(...valid.args); containers.add(valid.name); @@ -348,32 +354,61 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); - it('stops a running agent at the captured invocation deadline', () => { - const data = fixture(); - const late = createContainerProfile({ invocation: invocation(data.clone, 'planning', 'codex', 4_000), - filesystems: data.filesystems, inputDirectory: data.input, command: ['sh', '-c', 'sleep 30'], - codexAuthFile: data.fakeAuth, imageId }); - profiles.push(late); + it('bounds profile revalidation by the invocation deadline, even with the default budget', () => { + const data = fixture(), captured = Date.now(), late = profile(data, 'planning', 'noop', { deadlineMs: 6_000 }); + execFileSync('sleep', [String(Math.max(0, captured + 6_500 - Date.now()) / 1000)]); + expect(() => assertContainerProfile(late)).toThrow('deadline has passed'); + }, 60_000); + + it('refuses to launch once the captured invocation deadline has passed', () => { + const data = fixture(), captured = Date.now(), late = profile(data, 'planning', 'noop', { deadlineMs: 6_000 }); + execFileSync('sleep', [String(Math.max(0, captured + 6_500 - Date.now()) / 1000)]); + expect(() => runContainer(late, 60_000)).toThrow('deadline has passed'); + }, 60_000); + + it('refuses to build a profile once the invocation deadline has passed', () => { + const data = fixture(), trusted = governed(invocation(data.clone, 'planning', 'codex', 5_000)); + const wait = Math.max(0, trusted.invocation.deadline - Date.now() + 500); + execFileSync('sleep', [String(wait / 1000)]); const started = performance.now(); - expect(() => runContainer(late, 60_000)).toThrow(); - expect(performance.now() - started).toBeLessThan(15_000); + expect(() => createContainerProfile({ ...trusted, filesystems: data.filesystems, inputDirectory: data.input, + codexAuthFile: data.fakeAuth, imageId })).toThrow('deadline has passed'); + expect(performance.now() - started).toBeLessThan(2_000); }, 60_000); it('refuses an invocation copied from a captured request with a different phase', () => { - const data = fixture(), captured = invocation(data.clone, 'review'); - const forged = { ...captured, phase: 'execute' as Phase }; - expect(() => createContainerProfile({ invocation: forged, filesystems: data.filesystems, - inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId })).toThrow('captured'); + const data = fixture(), trusted = governed(invocation(data.clone, 'review')); + const forged = { ...trusted.invocation, phase: 'execute' as Phase }; + expect(() => createContainerProfile({ ...trusted, invocation: forged, filesystems: data.filesystems, + inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId })).toThrow('captured'); + }, 60_000); + + it('removes the claimed vendor network when profile creation fails after the claim', () => { + const data = fixture(); + expect(() => profile(data, 'planning', 'noop', { codexAuthFile: join(data.root, 'missing-auth.json') })).toThrow(); + const orphan = vendorNetworks.at(-1)!; + expect(spawnSync('docker', ['network', 'inspect', orphan.name], { stdio: 'ignore' }).status).not.toBe(0); + expect(spawnSync('docker', ['container', 'inspect', orphan.proxyContainer], { stdio: 'ignore' }).status).not.toBe(0); + }, 60_000); + + it('does not let a copied profile start or remove the original container', () => { + const data = fixture(), live = profile(data, 'planning', 'noop'); + expect(createValidatedContainer(live)).toBe(live.name); containers.add(live.name); + const copy = Object.freeze({ ...live }); + expect(() => startValidatedContainer(copy)).toThrow('trusted profile builder'); + expect(() => runContainer(copy)).toThrow('trusted profile builder'); + expect(spawnSync('docker', ['container', 'inspect', live.name], { stdio: 'ignore' }).status).toBe(0); + docker('rm', '--force', live.name); containers.delete(live.name); }, 60_000); it('refuses a Codex auth path that is a link without resolving it', () => { const data = fixture(), link = join(data.root, 'auth-link.json'); symlinkSync(data.fakeAuth, link); - expect(() => profile(data, 'planning', ['true'], { codexAuthFile: link })).toThrow('not a link'); + expect(() => profile(data, 'planning', 'noop', { codexAuthFile: link })).toThrow('not a link'); }, 60_000); it('rejects an alternate Docker runtime that may not honour the checked isolation', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); docker(...valid.args.map(arg => arg === '--runtime=runc' ? '--runtime=io.containerd.runc.v2' : arg)); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); @@ -381,7 +416,7 @@ describe('real Docker agent isolation', () => { }, 60_000); it('rejects a restart policy that could relaunch the agent after it exits', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); const imageIndex = valid.args.indexOf(imageId); docker(...valid.args.slice(0, imageIndex), '--restart=always', ...valid.args.slice(imageIndex)); containers.add(valid.name); @@ -390,7 +425,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); @@ -406,15 +441,28 @@ 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', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); - const args = valid.args.map(value => value === '--network=none' ? '--network=bridge' : value); + it('rejects an unauthorized network before the container can start', () => { + 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'); 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 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); + + 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); @@ -427,8 +475,33 @@ 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('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' }); + 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', () => { - 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); @@ -449,34 +522,27 @@ 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 output = docker('start', '--attach', authProfile.name); - docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); - expect(output).toBe('codeboost-schema-marker'); + const authProfile = profile(data, 'planning', policy => createCodexCommand(policy, + 'Reply only with this exact marker: codeboost-schema-marker'), + { authProbe: true, codexAuthFile: authFile, deadlineMs: 5 * 60_000 }); + // The production launch path: create, validate, start and remove. + const output = runContainer(authProfile, 5 * 60_000); + 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, - 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 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, deadlineMs: 5 * 60_000 }); + // The production launch path, with the token passed only as the Claude profile's secret. + const output = runContainer(authProfile, 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'); + // Tolerate one wrapping pair of backticks or quotes, but nothing else around the value. + const value = envelope.result?.trim().replace(/^(`+|"|')([^]*)\1$/, '$2').trim(); + expect(value).toBe('codeboost-schema-marker'); }, 6 * 60_000); } }); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts new file mode 100644 index 0000000..93e8a63 --- /dev/null +++ b/test/agent-network.test.ts @@ -0,0 +1,194 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildAgentImage } from '../agents/container/image.ts'; +import { assertVendorNetwork, createVendorNetwork, removeVendorNetwork, VENDOR_HOSTS, + type VendorNetwork } from '../agents/network/network.ts'; +import { captureInvocation, type InvocationInput } from '../agents/contract.ts'; + +let imageId = '', network: VendorNetwork, invocation: InvocationInput; +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(); + // Capture after the image build, so a cold build cannot spend the invocation's deadline before allocation. + invocation = captureInvocation({ + clone: { id: 'clone-network', taskId: 'task-network', directory: '/tmp/network', head: 'a'.repeat(40) }, + vendor: 'claude', phase: 'planning', approvedArgv: [], deadline: Date.now() + 10 * 60_000, attemptId: 'network-probe', + context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, + }); + network = createVendorNetwork(invocation, imageId); +}, 10 * 60_000); +// If setup failed there is no network, and a teardown error would hide the setup failure. +afterAll(() => { if (network) removeVendorNetwork(network); }, 60_000); + +describe('vendor-only egress', () => { + it('keeps failed allocation and its cleanup inside the caller deadline', () => { + const shim = mkdtempSync(join(tmpdir(), 'docker-shim-')); + const realDocker = execFileSync('sh', ['-c', 'command -v docker'], { encoding: 'utf8' }).trim(); + // Every network operation hangs, so both setup and cleanup can only end by deadline. + writeFileSync(join(shim, 'docker'), ['#!/bin/sh', 'if [ "$1" = network ]; then exec sleep 30; fi', + `exec '${realDocker}' "$@"`].join('\n'), { mode: 0o755 }); + const path = process.env.PATH, started = performance.now(); + process.env.PATH = `${shim}:${path}`; + try { expect(() => createVendorNetwork(invocation, imageId, 3_000)).toThrow(); } + finally { process.env.PATH = path; rmSync(shim, { recursive: true, force: true }); } + expect(performance.now() - started).toBeLessThan(6_000); + }, 60_000); + + it('removes a network that lands in the daemon after its create client was killed', () => { + const shim = mkdtempSync(join(tmpdir(), 'docker-shim-')), requested = join(shim, 'network-name'); + const realDocker = execFileSync('sh', ['-c', 'command -v docker'], { encoding: 'utf8' }).trim(); + // The create client hangs until killed, and the real create lands after that, inside the cleanup reserve. + // The shim records this test's network name, since other suites may create egress networks concurrently. + writeFileSync(join(shim, 'docker'), ['#!/bin/sh', + `if [ "$1" = network ] && [ "$2" = create ]; then for arg; do last="$arg"; done; printf %s "$last" > '${requested}'; ` + + `( sleep 7; exec '${realDocker}' "$@" ) >/dev/null 2>&1 createVendorNetwork(lateInvocation, imageId, 9_000)).toThrow(); + name = readFileSync(requested, 'utf8'); + } finally { process.env.PATH = path; rmSync(shim, { recursive: true, force: true }); } + execFileSync('sleep', ['3']); + const orphaned = spawnSync('docker', ['network', 'inspect', name], { stdio: 'ignore' }).status === 0; + if (orphaned) docker('network', 'rm', name); + expect(name).toMatch(/^codeboost-egress-/); + expect(orphaned).toBe(false); + }, 60_000); + + it('does not delete a same-named stand-in when setup fails after the proxy exists', () => { + const shim = mkdtempSync(join(tmpdir(), 'docker-shim-')), recorded = join(shim, 'impostor'); + const realDocker = execFileSync('sh', ['-c', 'command -v docker'], { encoding: 'utf8' }).trim(); + // The readiness exec swaps the proxy for a same-named, same-labelled stand-in, then fails setup. + writeFileSync(join(shim, 'docker'), ['#!/bin/sh', 'if [ "$1" = exec ]; then', + ` name=$('${realDocker}' inspect -f '{{.Name}}' "$2" | sed 's#^/##')`, + ` label=$('${realDocker}' inspect -f '{{index .Config.Labels "io.codeboost.egress"}}' "$2")`, + ` '${realDocker}' rm --force "$2" >/dev/null`, + ` '${realDocker}' run --detach --name "$name" --label "io.codeboost.egress=$label" --entrypoint sleep ${imageId} 300 >/dev/null`, + ` printf %s "$name" > '${recorded}'; exit 1`, 'fi', `exec '${realDocker}' "$@"`].join('\n'), { mode: 0o755 }); + const failing = captureInvocation({ ...invocation, attemptId: `failed-setup-${randomUUID()}`, + deadline: Date.now() + 60_000 }); + const path = process.env.PATH; + process.env.PATH = `${shim}:${path}`; + let impostor = ''; + try { + expect(() => createVendorNetwork(failing, imageId)).toThrow(); + impostor = readFileSync(recorded, 'utf8'); + } finally { process.env.PATH = path; rmSync(shim, { recursive: true, force: true }); } + const survived = spawnSync('docker', ['container', 'inspect', impostor], { stdio: 'ignore' }).status === 0; + spawnSync('docker', ['rm', '--force', impostor], { stdio: 'ignore' }); + expect(impostor).toMatch(/^codeboost-proxy-/); + expect(survived).toBe(true); + }, 60_000); + + 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('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'); + 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); + + it('rejects a proxy whose restart policy was changed', () => { + docker('update', '--restart=always', network.proxyContainer); + try { expect(() => assertVendorNetwork(network, invocation)).toThrow('network or proxy changed'); } + finally { docker('update', '--restart=no', network.proxyContainer); } + }, 60_000); + + it.each([ + ['nothing but a new object ID', []], + ['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-${randomUUID()}`, + deadline: Date.now() + 60_000 }); + const replacement = createVendorNetwork(replacementInvocation, imageId); + const inspected = JSON.parse(docker('container', 'inspect', replacement.proxyContainer))[0] as + { Config: { Labels: Record }; NetworkSettings: { Networks: Record } }; + const allocation = inspected.Config.Labels['io.codeboost.egress']; + const proxyIp = inspected.NetworkSettings.Networks[replacement.name]!.IPAddress; + try { + docker('rm', '--force', replacement.proxyContainer); + // Same name, label, image, lockdown and IP as the original, so only the extra option and the object ID differ. + docker('run', '--detach', '--name', replacement.proxyContainer, '--read-only', '--user', '10001:10001', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', + '--pids-limit=64', '--memory=64m', '--memory-swap=64m', '--cpus=.25', ...extra, + '--network', replacement.name, '--ip', proxyIp, '--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 { + // Cleanup removes only the objects it created, so the stand-in proxy must go first. + spawnSync('docker', ['rm', '--force', replacement.proxyContainer], { stdio: 'ignore' }); + removeVendorNetwork(replacement); + } + }, 60_000); +}); diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts new file mode 100644 index 0000000..729f2f1 --- /dev/null +++ b/test/agent-policy.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; +import { assertAgentCommand, assertAgentTool, codexBaseArguments, createClaudeCommand, createCodexCommand, + createPhasePolicy, dispatchApprovedCommand } from '../agents/policy.ts'; + +let attempt = 0; +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, phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['npm', 'test']], + deadline: 2000, attemptId: `attempt-${phase}-${++attempt}`, + context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, +}, 1000); + +describe('agent phase policy', () => { + it('refuses to build a policy from a request that was not captured', () => { + const forged = { ...request('review'), phase: 'execute' as Phase, approvedArgv: [['sh', '-c', 'anything']] }; + expect(() => createPhasePolicy(forged)).toThrow('captured'); + }); + 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('keeps an option-like prompt after -- so neither CLI parses it as a flag', () => { + const prompt = '--dangerously-bypass-approvals-and-sandbox'; + const claude = createClaudeCommand(createPhasePolicy(request('planning')), prompt).argv; + const codex = createCodexCommand(createPhasePolicy(request('planning', 'codex')), prompt).argv; + for (const argv of [claude, codex]) { + expect(argv.at(-1)).toBe(prompt); + expect(argv.at(-2)).toBe('--'); + expect(argv.indexOf(prompt)).toBe(argv.length - 1); + } + }); + + 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.').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'); + 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(codexPolicy, 'Inspect the schema.'); + expect(codex.argv).toContain('features.shell_tool=false'); + 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'); + }); +}); diff --git a/test/agent-proxy.test.ts b/test/agent-proxy.test.ts new file mode 100644 index 0000000..1d33514 --- /dev/null +++ b/test/agent-proxy.test.ts @@ -0,0 +1,122 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { connect, createServer, type AddressInfo, type Server } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; + +const proxyScript = new URL('../agents/network/proxy.mjs', import.meta.url).pathname; +const children: ChildProcess[] = []; +const servers: Server[] = []; + +const freePort = () => new Promise(resolve => { + const server = createServer().listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo; + server.close(() => resolve(port)); + }); +}); +const waitForListen = async (port: number) => { + for (let attempt = 0; attempt < 100; attempt++) { + const open = await new Promise(resolve => { + const socket = connect(port, '127.0.0.1'); + socket.once('connect', () => { socket.destroy(); resolve(true); }); + socket.once('error', () => resolve(false)); + }); + if (open) return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error('Proxy did not start listening.'); +}; + +async function startProxy() { + const received: Buffer[] = []; + const upstream = createServer(socket => socket.on('data', (chunk: Buffer) => received.push(chunk))); + servers.push(upstream); + await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)); + const upstreamPort = (upstream.address() as AddressInfo).port, proxyPort = await freePort(); + // Delay outbound connects in the proxy process, so the gap between parsing CONNECT and reaching the + // upstream is reliably wide. + const slowConnect = 'data:text/javascript,import net from "node:net";const connect=net.Socket.prototype.connect;' + + 'net.Socket.prototype.connect=function(...args){setTimeout(()=>connect.apply(this,args),300);return this;};'; + const child = spawn(process.execPath, ['--import', slowConnect, proxyScript], { stdio: 'ignore', env: { + CODEBOOST_ALLOWED_HOSTS: 'localhost', CODEBOOST_PROXY_PORT: String(proxyPort), + CODEBOOST_UPSTREAM_PORT: String(upstreamPort) } }); + children.push(child); + await waitForListen(proxyPort); + return { proxyPort, upstreamPort, received: () => Buffer.concat(received).toString('utf8') }; +} + +afterEach(() => { + for (const child of children.splice(0)) child.kill('SIGKILL'); + for (const server of servers.splice(0)) server.close(); +}); + +describe('vendor egress proxy', () => { + it('forwards bytes a client sends after CONNECT but before the tunnel is established', async () => { + const { proxyPort, upstreamPort, received } = await startProxy(); + const client = connect(proxyPort, '127.0.0.1'); + await new Promise(resolve => client.once('connect', resolve)); + // Two writes: the header, then payload the client sends without waiting for the 200 response. + client.write(`CONNECT localhost:${upstreamPort} HTTP/1.1\r\nHost: localhost\r\n\r\n`); + // Long enough to arrive as a separate read, well before the delayed upstream connect completes. + await new Promise(resolve => setTimeout(resolve, 100)); + client.write('early-client-hello'); + const deadline = Date.now() + 3_000; + while (!received().includes('early-client-hello') && Date.now() < deadline) + await new Promise(resolve => setTimeout(resolve, 20)); + client.destroy(); + expect(received()).toContain('early-client-hello'); + }); + + it('forwards a large payload that arrives in the same read as the CONNECT header', async () => { + const { proxyPort, upstreamPort, received } = await startProxy(); + const client = connect(proxyPort, '127.0.0.1'); + await new Promise(resolve => client.once('connect', resolve)); + const payload = `large-hello-${'x'.repeat(16 * 1024)}`; + client.write(`CONNECT localhost:${upstreamPort} HTTP/1.1\r\nHost: localhost\r\n\r\n${payload}`); + const deadline = Date.now() + 3_000; + while (received().length < payload.length && Date.now() < deadline) + await new Promise(resolve => setTimeout(resolve, 20)); + client.destroy(); + expect(received()).toBe(payload); + }); + + it('refuses a CONNECT header larger than 8 KiB', async () => { + const { proxyPort, upstreamPort } = await startProxy(); + const client = connect(proxyPort, '127.0.0.1'); + let response = ''; + client.on('data', chunk => { response += chunk.toString('utf8'); }); + const closed = new Promise(resolve => client.once('close', resolve)); + client.write(`CONNECT localhost:${upstreamPort} HTTP/1.1\r\nX-Pad: ${'p'.repeat(9 * 1024)}\r\n\r\n`); + await closed; + expect(response).toMatch(/^HTTP\/1\.1 431 /); + }); + + it('refuses a streamed unterminated header without buffering it, and keeps serving', async () => { + const { proxyPort, upstreamPort, received } = await startProxy(); + const flood = connect(proxyPort, '127.0.0.1'); + let response = ''; + flood.on('data', chunk => { response += chunk.toString('utf8'); }); + flood.on('error', () => undefined); + const closed = new Promise(resolve => flood.once('close', resolve)); + flood.write(`CONNECT localhost:${upstreamPort} HTTP/1.1\r\nX-Pad: ${'p'.repeat(1024 * 1024)}`); + await closed; + expect(response).toMatch(/^HTTP\/1\.1 431 /); + const client = connect(proxyPort, '127.0.0.1'); + await new Promise(resolve => client.once('connect', resolve)); + client.write(`CONNECT localhost:${upstreamPort} HTTP/1.1\r\n\r\nstill-serving`); + const deadline = Date.now() + 3_000; + while (!received().includes('still-serving') && Date.now() < deadline) + await new Promise(resolve => setTimeout(resolve, 20)); + client.destroy(); + expect(received()).toContain('still-serving'); + }); + + it('refuses hosts outside the vendor allowlist', async () => { + const { proxyPort, upstreamPort } = await startProxy(); + const client = connect(proxyPort, '127.0.0.1'); + let response = ''; + client.on('data', chunk => { response += chunk.toString('utf8'); }); + const closed = new Promise(resolve => client.once('close', resolve)); + client.write(`CONNECT example.com:${upstreamPort} HTTP/1.1\r\n\r\n`); + await closed; + expect(response).toMatch(/^HTTP\/1\.1 403 Forbidden/); + }); +}); diff --git a/test/questions.test.ts b/test/questions.test.ts index 5e3692e..74eaf96 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}));}