From abed204d12d202e25b33213d3679adf40ff7b3b2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 10:34:52 -0700 Subject: [PATCH 01/20] Add vendor-only egress and phase policy --- .github/workflows/agent-isolation.yml | 2 +- agents/container/Dockerfile | 3 +- agents/container/image.ts | 8 +- agents/container/profile.ts | 21 ++++- agents/container/run.ts | 13 ++- agents/network/network.ts | 130 ++++++++++++++++++++++++++ agents/network/proxy.mjs | 44 +++++++++ agents/policy.ts | 61 ++++++++++++ test/agent-container.test.ts | 95 +++++++++++-------- test/agent-network.test.ts | 48 ++++++++++ test/agent-policy.test.ts | 50 ++++++++++ 11 files changed, 425 insertions(+), 50 deletions(-) create mode 100644 agents/network/network.ts create mode 100644 agents/network/proxy.mjs create mode 100644 agents/policy.ts create mode 100644 test/agent-network.test.ts create mode 100644 test/agent-policy.test.ts diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index f709abf..f851a9b 100644 --- a/.github/workflows/agent-isolation.yml +++ b/.github/workflows/agent-isolation.yml @@ -28,4 +28,4 @@ jobs: cache: npm - run: npm ci --ignore-scripts - run: npm run typecheck - - run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts + - run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts test/agent-network.test.ts test/agent-policy.test.ts diff --git a/agents/container/Dockerfile b/agents/container/Dockerfile index 6711760..b1d197a 100644 --- a/agents/container/Dockerfile +++ b/agents/container/Dockerfile @@ -11,7 +11,8 @@ RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \ && install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost \ && install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git -COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe +COPY --chmod=0555 container/probe.sh /usr/local/bin/codeboost-container-probe +COPY --chmod=0444 network/proxy.mjs /usr/local/lib/codeboost-egress-proxy.mjs LABEL org.opencontainers.image.base.name="docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1" \ io.codeboost.codex.version="0.153.4" \ diff --git a/agents/container/image.ts b/agents/container/image.ts index cdfc655..a990fb3 100644 --- a/agents/container/image.ts +++ b/agents/container/image.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; export const AGENT_IMAGE = 'codeboost-agent:node26-codex0.153.4-claude2.1.281'; @@ -7,7 +7,8 @@ export const BASE_IMAGE = 'docker.io/library/node:26.7.0-bookworm@sha256:e929171 export const CODEX_VERSION = '0.153.4'; export const CLAUDE_VERSION = '2.1.281'; -const context = dirname(fileURLToPath(import.meta.url)); +const containerDirectory = dirname(fileURLToPath(import.meta.url)); +const context = dirname(containerDirectory); const trustedImages = new Set(); export function assertBuiltAgentImage(imageId: string): void { @@ -22,7 +23,8 @@ export function buildAgentImage(timeoutMs = 10 * 60_000): string { if (value <= 0) throw new Error('Agent image build exceeded its overall deadline.'); return value; }; - execFileSync('docker', ['build', '--pull=false', '--tag', AGENT_IMAGE, context], { + execFileSync('docker', ['build', '--pull=false', '--file', join(containerDirectory, 'Dockerfile'), + '--tag', AGENT_IMAGE, context], { timeout: remaining(), killSignal: 'SIGKILL', stdio: ['ignore', 'inherit', 'inherit'], }); const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], { diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 41a85ed..50ea162 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, type VendorNetwork } from '../network/network.ts'; +import { assertPhasePolicy, type PhasePolicy } from '../policy.ts'; export interface ContainerProfile { readonly name: string; readonly args: readonly string[]; @@ -17,6 +19,8 @@ export interface ContainerProfile { readonly codexAuthFile?: string; readonly command: readonly string[]; readonly ownershipId: string; + readonly network: VendorNetwork; + readonly policy: PhasePolicy; } export interface ProfileOptions { readonly invocation: InvocationInput; @@ -26,6 +30,8 @@ export interface ProfileOptions { readonly imageId: string; readonly codexAuthFile?: string; readonly claudeToken?: string; + readonly network: VendorNetwork; + readonly policy: PhasePolicy; } interface FileIdentity { @@ -40,7 +46,8 @@ interface FileIdentity { } interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity; readonly cleanupDirectories: readonly string[]; readonly filesystems: TaskFilesystems; - readonly clone: InvocationInput['clone']; readonly 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(); @@ -109,6 +116,8 @@ export function assertContainerProfile(profile: ContainerProfile): void { const expected = identities.get(profile); if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); assertTaskFilesystems(expected.filesystems, expected.clone); + assertVendorNetwork(expected.network, profile.vendor); + assertPhasePolicy(expected.policy, expected.invocation); const actual = captureInput(expected.inputDirectory); if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) throw new Error('Schema input changed after the profile was captured.'); @@ -156,6 +165,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container profile requires the immutable built image ID.'); assertBuiltAgentImage(options.imageId); assertTaskFilesystems(filesystems, invocation.clone); + assertVendorNetwork(options.network, invocation.vendor); + assertPhasePolicy(options.policy, invocation); const sourceInput = captureInput(options.inputDirectory); if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); @@ -191,9 +202,11 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--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}`, '--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', @@ -212,11 +225,11 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile, - command: Object.freeze([...options.command]), ownershipId }); + command: Object.freeze([...options.command]), ownershipId, network: options.network, policy: options.policy }); identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema, auth: authIdentity, cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone, - deadline: invocation.deadline })); + deadline: invocation.deadline, network: options.network, policy: options.policy, invocation })); return profile; } catch (error) { try { removeOwnedDirectories(cleanupDirectories); } diff --git a/agents/container/run.ts b/agents/container/run.ts index b26847a..c07c9ad 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -103,6 +103,7 @@ type Inspect = { Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; + NetworkSettings: { Networks: Record }; }; /** Validate daemon-resolved configuration before starting an agent. */ @@ -131,7 +132,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 +147,8 @@ 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(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 +226,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,7 +238,10 @@ export function validateContainer(container: string, profile: ContainerProfile, || environment.get('CODEBOOST_METADATA_BYTES') !== String(profile.filesystems.metadataBytes) || environment.get('CODEBOOST_METADATA_INODES') !== String(profile.filesystems.metadataInodes) || environment.get('npm_config_cache') !== '/tmp/npm-cache' - || environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache') + || environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache' + || environment.get('HTTPS_PROXY') !== profile.network.proxyUrl + || environment.get('HTTP_PROXY') !== profile.network.proxyUrl + || environment.get('NO_PROXY') !== 'localhost,127.0.0.1') throw new Error('Container isolation environment changed.'); if (profile.vendor === 'codex' && (names.includes('CLAUDE_CODE_OAUTH_TOKEN') || environment.get('CODEX_HOME') !== '/run/codeboost-auth/codex')) diff --git a/agents/network/network.ts b/agents/network/network.ts new file mode 100644 index 0000000..0e6fbe2 --- /dev/null +++ b/agents/network/network.ts @@ -0,0 +1,130 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import type { InvocationInput } from '../contract.ts'; +import { assertBuiltAgentImage } from '../container/image.ts'; + +export const VENDOR_HOSTS = Object.freeze({ + claude: Object.freeze(['api.anthropic.com']), + codex: Object.freeze(['api.openai.com', 'chatgpt.com']), +} satisfies Record); + +export interface VendorNetwork { + readonly name: string; + readonly proxyContainer: string; + readonly proxyUrl: string; + readonly vendor: InvocationInput['vendor']; +} +interface NetworkIdentity { readonly allocationId: string; readonly imageId: string } +const identities = new WeakMap(); +const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); +const deadline = (timeoutMs: number) => { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Network deadline must be a positive integer.'); + const end = performance.now() + timeoutMs; + return () => { + const value = Math.ceil(end - performance.now()); + if (value <= 0) throw new Error('Vendor network operation exceeded its overall deadline.'); + return value; + }; +}; +const docker = (args: readonly string[], timeout: number) => execFileSync('docker', [...args], { + encoding: 'utf8', timeout, killSignal: 'SIGKILL', env: environment(), stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); +const absent = (result: ReturnType) => result.status !== 0 && !result.error + && /No such (?:object|container|network)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); +const remove = (args: readonly string[], inspect: readonly string[], remaining: () => number, kind: string, + allocationId: string) => { + const before = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (before.status !== 0) { + if (absent(before)) return; + throw new Error(`Failed to establish ownership of ${kind}.`); + } + const inspected = JSON.parse(before.stdout || '[]')[0] as + { Labels?: Record; Config?: { Labels?: Record } } | undefined; + const labels = inspected?.Labels ?? inspected?.Config?.Labels; + if (labels?.['io.codeboost.egress'] !== allocationId) throw new Error(`Refused to remove unowned ${kind}.`); + const result = spawnSync('docker', [...args], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.status === 0) return; + const check = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`); +}; + +export function assertVendorNetwork(network: VendorNetwork, vendor?: InvocationInput['vendor']): void { + const identity = identities.get(network); + if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); + if (vendor && network.vendor !== vendor) throw new Error('Vendor network does not match the invocation vendor.'); + assertBuiltAgentImage(identity.imageId); +} + +export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: string, + timeoutMs = 60_000): VendorNetwork { + assertBuiltAgentImage(imageId); + const remaining = deadline(timeoutMs), allocationId = randomUUID(); + const name = `codeboost-egress-${vendor}-${randomUUID()}`; + const proxyContainer = `codeboost-proxy-${vendor}-${randomUUID()}`; + let networkPlanned = false, proxyPlanned = false; + try { + networkPlanned = true; + docker(['network', 'create', '--internal', '--driver', 'bridge', + '--label', `io.codeboost.egress=${allocationId}`, name], remaining()); + proxyPlanned = true; + docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', + '--cpus=.25', '--network', name, '--network-alias', 'codeboost-proxy', + '--label', `io.codeboost.egress=${allocationId}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`, + '--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs'], remaining()); + docker(['network', 'connect', 'bridge', proxyContainer], remaining()); + docker(['exec', proxyContainer, 'node', '-e', [ + "const net=require('node:net');let attempts=0;", + "const check=()=>{const socket=net.connect(3128,'127.0.0.1');", + "socket.once('connect',()=>{socket.destroy();process.exit(0)});", + "socket.once('error',()=>{socket.destroy();if(++attempts===50)process.exit(1);setTimeout(check,20)})};check();", + ].join('')], remaining()); + const inspect = JSON.parse(docker(['container', 'inspect', proxyContainer], remaining()))[0] as + { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record; Env?: string[] }; + HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; + SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number }; + NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + const inspectedNetwork = JSON.parse(docker(['network', 'inspect', name], remaining()))[0] as + { Internal?: boolean; Driver?: string; Labels?: Record } | undefined; + const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); + if (!inspect?.State?.Running || inspect.Config?.Image !== imageId || inspect.Config?.User !== '10001:10001' + || inspect.Config?.Labels?.['io.codeboost.egress'] !== allocationId || !inspect.HostConfig?.ReadonlyRootfs + || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1 + || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') + || inspect.HostConfig.Memory !== 64 * 1024 * 1024 + || inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000 + || JSON.stringify(networks) !== JSON.stringify(['bridge', name].sort()) + || inspect.Mounts?.length || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`) + || !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge' + || inspectedNetwork.Labels?.['io.codeboost.egress'] !== allocationId) + throw new Error('Vendor proxy does not match its pinned isolation profile.'); + remaining(); + const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor }); + identities.set(network, Object.freeze({ allocationId, imageId })); + return network; + } catch (error) { + const failures: unknown[] = []; + if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer], + deadline(30_000), 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + if (networkPlanned) try { remove(['network', 'rm', name], ['network', 'inspect', name], + deadline(30_000), 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + if (failures.length) throw new AggregateError([error, ...failures], 'Vendor network creation and cleanup failed.'); + throw error; + } +} + +export function removeVendorNetwork(network: VendorNetwork): void { + assertVendorNetwork(network); + const allocationId = identities.get(network)!.allocationId; + const remaining = deadline(30_000), failures: unknown[] = []; + try { remove(['rm', '--force', network.proxyContainer], ['container', 'inspect', network.proxyContainer], + remaining, 'vendor proxy', allocationId); } catch (error) { failures.push(error); } + try { remove(['network', 'rm', network.name], ['network', 'inspect', network.name], + remaining, 'vendor network', allocationId); } catch (error) { failures.push(error); } + if (failures.length) throw new AggregateError(failures, 'Vendor network cleanup did not settle.'); + identities.delete(network); +} diff --git a/agents/network/proxy.mjs b/agents/network/proxy.mjs new file mode 100644 index 0000000..00cba4f --- /dev/null +++ b/agents/network/proxy.mjs @@ -0,0 +1,44 @@ +import { createServer, connect } from 'node:net'; + +const allowed = new Set((process.env.CODEBOOST_ALLOWED_HOSTS ?? '').split(',').filter(Boolean)); +if (!allowed.size) throw new Error('CODEBOOST_ALLOWED_HOSTS is required.'); + +const refuse = (socket, status = '403 Forbidden') => { + socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); +}; + +createServer(client => { + client.setTimeout(300_000, () => client.destroy()); + let request = Buffer.alloc(0), settled = false; + const receive = chunk => { + if (settled) return; + request = Buffer.concat([request, chunk], request.length + chunk.length); + if (request.length > 8192) { + settled = true; + refuse(client, '431 Request Header Fields Too Large'); + return; + } + const boundary = request.indexOf('\r\n\r\n'); + if (boundary < 0) return; + settled = true; + const line = request.subarray(0, request.indexOf('\r\n')).toString('ascii'); + const match = /^CONNECT ([a-z0-9.-]+):443 HTTP\/1\.[01]$/.exec(line); + const host = match?.[1]; + if (!host || !allowed.has(host)) { + refuse(client); + return; + } + const upstream = connect({ host, port: 443 }); + upstream.setTimeout(300_000, () => upstream.destroy()); + upstream.once('connect', () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + const remainder = request.subarray(boundary + 4); + if (remainder.length) upstream.write(remainder); + client.pipe(upstream).pipe(client); + }); + upstream.once('error', () => refuse(client, '502 Bad Gateway')); + client.once('error', () => upstream.destroy()); + }; + client.on('data', receive); + client.once('error', () => undefined); +}).listen(3128, '0.0.0.0'); diff --git a/agents/policy.ts b/agents/policy.ts new file mode 100644 index 0000000..b60aa54 --- /dev/null +++ b/agents/policy.ts @@ -0,0 +1,61 @@ +import type { InvocationInput, Phase } from './contract.ts'; +import { permitsCommand } from './contract.ts'; + +export type AgentTool = 'read' | 'list' | 'search' | 'write' | 'edit' | 'runner-command'; +export interface PhasePolicy { + readonly phase: Phase; + readonly worktree: 'read-only' | 'read-write'; + readonly tools: readonly AgentTool[]; + readonly web: false; + readonly mcp: false; +} +interface PolicyIdentity { readonly invocation: InvocationInput } +const identities = new WeakMap(); + +export function createPhasePolicy(invocation: InvocationInput): PhasePolicy { + const writable = invocation.phase === 'execute' || invocation.phase === 'fix'; + const tools: AgentTool[] = ['read', 'list', 'search']; + if (invocation.phase === 'review' || writable) tools.push('runner-command'); + if (writable) tools.push('write', 'edit'); + const policy = Object.freeze({ phase: invocation.phase, worktree: writable ? 'read-write' : 'read-only', + tools: Object.freeze(tools), web: false as const, mcp: false as const }); + identities.set(policy, Object.freeze({ invocation })); + return policy; +} + +export function assertPhasePolicy(policy: PhasePolicy, invocation?: InvocationInput): InvocationInput { + const identity = identities.get(policy); + if (!identity) throw new Error('Phase policy was not created by the trusted policy builder.'); + if (invocation && identity.invocation !== invocation) throw new Error('Phase policy does not belong to this invocation.'); + return identity.invocation; +} + +export function assertAgentTool(policy: PhasePolicy, tool: AgentTool): void { + assertPhasePolicy(policy); + if (!policy.tools.includes(tool)) throw new Error(`${tool} is forbidden during ${policy.phase}.`); +} + +export function dispatchApprovedCommand(policy: PhasePolicy, argv: readonly string[], + execute: (argv: readonly string[]) => T): T { + const invocation = assertPhasePolicy(policy); + assertAgentTool(policy, 'runner-command'); + if (!permitsCommand(invocation, argv)) throw new Error('Command argv was not approved exactly for this invocation.'); + return execute(Object.freeze([...argv])); +} + +export function createClaudeCommand(policy: PhasePolicy, prompt: string): readonly string[] { + if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.'); + assertPhasePolicy(policy); + const writable = policy.worktree === 'read-write'; + const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep'; + return Object.freeze(['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config', + '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--no-chrome', '--permission-prompts', 'none', + '--permission-mode', writable ? 'acceptEdits' : 'plan', '--allowedTools', allowed, + '--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir', '/run/codeboost-input']); +} + +export function codexBaseArguments(policy: PhasePolicy): readonly string[] { + assertPhasePolicy(policy); + return Object.freeze(['codex', '--strict-config', '--config', 'web_search="disabled"', + '--config', 'mcp_servers={}', '--ask-for-approval', 'never']); +} diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 2478dcc..d0776e3 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -10,12 +10,15 @@ import { createContainerProfile, disposeContainerProfile } from '../agents/conta import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, hasExactOptions, validateContainer } from '../agents/container/run.ts'; import { createTaskClone } from '../git/clone.ts'; +import { createVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../agents/network/network.ts'; +import { codexBaseArguments, createClaudeCommand, createPhasePolicy } from '../agents/policy.ts'; const roots: string[] = []; const taskFilesystems: ReturnType[] = []; const containers = new Set(); const profiles: ReturnType[] = []; let imageId = ''; +let vendorNetworks: Record<'claude' | 'codex', VendorNetwork>; const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const docker = (...args: string[]) => execFileSync('docker', args, { @@ -52,25 +55,31 @@ function invocation(clone: ReturnType, phase: Phase, ven context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', referencedCodeHash: 'code-1', stateVersion: 1 } }); } +const governed = (captured: InvocationInput) => ({ invocation: captured, policy: createPhasePolicy(captured) }); function profile(data: ReturnType, phase: Phase, command: string[], options: { vendor?: 'codex' | 'claude'; authProbe?: boolean; codexAuthFile?: string; claudeToken?: string; } = {}) { const vendor = options.vendor ?? 'codex'; - const base = createContainerProfile({ invocation: invocation(data.clone, phase, vendor), filesystems: data.filesystems, + const captured = invocation(data.clone, phase, vendor); + const base = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, inputDirectory: data.input, command, - imageId, + imageId, network: vendorNetworks[vendor], codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); profiles.push(base); return base; } -beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000); +beforeAll(() => { + imageId = buildAgentImage(); + vendorNetworks = { claude: createVendorNetwork('claude', imageId), codex: createVendorNetwork('codex', imageId) }; +}, 10 * 60_000); afterAll(() => { for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); for (const profile of profiles) disposeContainerProfile(profile); + for (const network of Object.values(vendorNetworks).reverse()) removeVendorNetwork(network); for (const root of roots.reverse()) { chmodSync(join(root, 'input'), 0o700); rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); @@ -78,6 +87,15 @@ afterAll(() => { }, 120_000); describe('real Docker agent isolation', () => { + it.each(['planning', 'questions', 'review', 'execute', 'fix'] as const)( + '%s applies its enforced worktree access profile', phase => { + const data = fixture(), writable = phase === 'execute' || phase === 'fix'; + const command = writable + ? ['sh', '-c', `set -eu; printf ${phase} > /work/${phase}.txt; test -f /work/${phase}.txt`] + : ['sh', '-c', `set -eu; ! touch /work/${phase}.txt 2>/dev/null; test ! -e /work/${phase}.txt`]; + expect(runContainer(profile(data, phase, command))).toBe(''); + }, 60_000); + it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { const data = fixture(); process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; @@ -167,21 +185,23 @@ describe('real Docker agent isolation', () => { it('rejects mixed credentials and unsupported command/profile inputs', () => { const data = fixture(); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'codex'), + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'codex')), filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - claudeToken: 'must-not-combine', imageId })).toThrow('only'); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId })).toThrow('OAuth'); - const claudeProfile = createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), + claudeToken: 'must-not-combine', imageId, network: vendorNetworks.codex })).toThrow('only'); + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'claude')), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, + network: vendorNetworks.claude })).toThrow('OAuth'); + const claudeProfile = createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'claude')), filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, - claudeToken: 'serialization-sentinel' }); + claudeToken: 'serialization-sentinel', network: vendorNetworks.claude }); expect(JSON.stringify(claudeProfile)).not.toContain('serialization-sentinel'); expect(() => createValidatedContainer(claudeProfile)).toThrow('OAuth environment credential'); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), - filesystems: data.filesystems, inputDirectory: data.input, command: [], imageId })).toThrow('argv'); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), + filesystems: data.filesystems, inputDirectory: data.input, command: [], imageId, + network: vendorNetworks.codex })).toThrow('argv'); + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - imageId: AGENT_IMAGE })).toThrow('immutable built image ID'); + imageId: AGENT_IMAGE, network: vendorNetworks.codex })).toThrow('immutable built image ID'); chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => profile(data, 'planning', ['true'])).toThrow('only one bounded'); }); @@ -212,14 +232,14 @@ describe('real Docker agent isolation', () => { ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); - expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), filesystems: { ...data.filesystems }, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - imageId })).toThrow('trusted allocator'); + imageId, network: vendorNetworks.codex })).toThrow('trusted allocator'); const other = fixture(); - expect(() => createContainerProfile({ invocation: invocation(other.clone, 'planning'), + expect(() => createContainerProfile({ ...governed(invocation(other.clone, 'planning')), filesystems: data.filesystems, inputDirectory: other.input, command: ['true'], codexAuthFile: other.fakeAuth, - imageId })).toThrow('do not belong to the invocation clone'); + imageId, network: vendorNetworks.codex })).toThrow('do not belong to the invocation clone'); writeFileSync(data.fakeAuth, '{"changed":true}'); expect(valid.codexAuthFile).not.toBe(data.fakeAuth); @@ -249,7 +269,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 +280,10 @@ describe('real Docker agent isolation', () => { it('does not remove an active container when a duplicate attempt name collides', () => { const data = fixture(), captured = invocation(data.clone, 'planning'); - const first = createContainerProfile({ invocation: captured, filesystems: data.filesystems, - inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId }); - const duplicate = createContainerProfile({ invocation: captured, filesystems: data.filesystems, - inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId }); + const first = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, + inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId, network: vendorNetworks.codex }); + const duplicate = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, + inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId, network: vendorNetworks.codex }); profiles.push(first, duplicate); docker(...first.args); containers.add(first.name); expect(() => createValidatedContainer(duplicate)).toThrow('Container creation failed and cleanup did not settle.'); @@ -406,9 +427,9 @@ describe('real Docker agent isolation', () => { expect(hasExactOptions([...expected, 'nosuid'].join(','), expected)).toBe(false); }, 60_000); - it('rejects a caller-mutated network before the container can start', () => { + it('rejects an unauthorized network before the container can start', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); - const args = valid.args.map(value => value === '--network=none' ? '--network=bridge' : value); + const args = valid.args.map(value => value.startsWith('--network=') ? '--network=bridge' : value); docker(...args); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); const state = JSON.parse(docker('container', 'inspect', valid.name))[0] as { State: { Status: string } }; @@ -449,27 +470,25 @@ describe('real Docker agent isolation', () => { it('runs the authenticated Codex startup path with isolated writable state', () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); - const authProfile = profile(data, 'planning', ['sh', '-c', [ - "codex exec --sandbox read-only --skip-git-repo-check --output-last-message /tmp/codex-output.txt 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.' >/tmp/codex-events.jsonl", - 'grep -Fx codeboost-schema-marker /tmp/codex-output.txt', - ].join('; ')], { authProbe: true, codexAuthFile: authFile }); - const args = authProfile.args.map(value => value === '--network=none' ? '--network=bridge' : value); - docker(...args); containers.add(authProfile.name); + const policy = createPhasePolicy(invocation(data.clone, 'planning', 'codex')); + const command = [...codexBaseArguments(policy), 'exec', '--sandbox', 'read-only', '--skip-git-repo-check', + 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.']; + const authProfile = profile(data, 'planning', command, { authProbe: true, codexAuthFile: authFile }); + docker(...authProfile.args); containers.add(authProfile.name); const output = docker('start', '--attach', authProfile.name); docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); - expect(output).toBe('codeboost-schema-marker'); + expect(output).toContain('codeboost-schema-marker'); }, 6 * 60_000); it('runs the authenticated Claude startup path with only its OAuth token', () => { const data = fixture(), token = process.env.CLAUDE_CODE_OAUTH_TOKEN; if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); - const authProfile = profile(data, 'planning', ['claude', '-p', - 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.', - '--output-format', 'json', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', - '--allowedTools', 'Read', '--add-dir', '/run/codeboost-input', - '--disallowedTools', 'WebFetch,WebSearch'], { vendor: 'claude', authProbe: true, claudeToken: token }); - const args = authProfile.args.map(value => value === '--network=none' ? '--network=bridge' : value); - const result = execFileSync('docker', args, { encoding: 'utf8', timeout: 60_000, + const policy = createPhasePolicy(invocation(data.clone, 'planning', 'claude')); + const command = createClaudeCommand(policy, + 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, without quotes or Markdown formatting.'); + const authProfile = profile(data, 'planning', [...command], + { vendor: 'claude', authProbe: true, claudeToken: token }); + const result = execFileSync('docker', authProfile.args, { encoding: 'utf8', timeout: 60_000, env: { PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, CLAUDE_CODE_OAUTH_TOKEN: token } }); void result; containers.add(authProfile.name); const output = docker('start', '--attach', authProfile.name); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts new file mode 100644 index 0000000..ab2361b --- /dev/null +++ b/test/agent-network.test.ts @@ -0,0 +1,48 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildAgentImage } from '../agents/container/image.ts'; +import { createVendorNetwork, removeVendorNetwork, VENDOR_HOSTS, type VendorNetwork } from '../agents/network/network.ts'; + +let imageId = '', network: VendorNetwork; +const docker = (...args: string[]) => execFileSync('docker', args, { + encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); +const curl = (url: string, direct = false) => spawnSync('docker', ['run', '--rm', `--network=${network.name}`, + '--env', `HTTPS_PROXY=${network.proxyUrl}`, ...(direct ? ['--env', 'NO_PROXY=*'] : []), + '--entrypoint', 'curl', imageId, '--silent', '--show-error', '--output', '/dev/null', '--write-out', '%{http_code}', + '--max-time', '15', url], { encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] }); + +beforeAll(() => { + imageId = buildAgentImage(); + network = createVendorNetwork('claude', imageId); +}, 10 * 60_000); +afterAll(() => removeVendorNetwork(network), 60_000); + +describe('vendor-only egress', () => { + it('pins the host list with each vendor profile', () => { + expect(VENDOR_HOSTS).toEqual({ claude: ['api.anthropic.com'], codex: ['api.openai.com', 'chatgpt.com'] }); + expect(Object.isFrozen(VENDOR_HOSTS.claude)).toBe(true); + expect(Object.isFrozen(VENDOR_HOSTS.codex)).toBe(true); + }); + + it('reaches the vendor through the proxy while blocking other and direct hosts', () => { + const vendor = curl('https://api.anthropic.com/'); + expect(vendor.status).toBe(0); + expect(vendor.stdout).toMatch(/^\d{3}$/); + expect(vendor.stdout).not.toBe('000'); + + const other = curl('https://example.com/'); + expect(other.status).not.toBe(0); + expect(other.stdout).toBe('000'); + expect(other.stderr).toContain('response 403'); + + const direct = curl('https://example.com/', true); + expect(direct.status).not.toBe(0); + expect(direct.stdout).toBe('000'); + }, 60_000); + + it('rejects a copied network capability', () => { + expect(() => createVendorNetwork('claude', imageId, 0)).toThrow('positive integer'); + expect(() => removeVendorNetwork({ ...network })).toThrow('trusted network builder'); + }); +}); diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts new file mode 100644 index 0000000..32e94b3 --- /dev/null +++ b/test/agent-policy.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; +import { assertAgentTool, codexBaseArguments, createClaudeCommand, createPhasePolicy, + dispatchApprovedCommand } from '../agents/policy.ts'; + +const request = (phase: Phase): InvocationInput => captureInvocation({ + clone: { id: 'clone-1', taskId: 'task-1', directory: '/tmp/task', head: 'a'.repeat(40) }, + vendor: 'claude', phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['npm', 'test']], + deadline: 2000, attemptId: `attempt-${phase}`, + context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, +}, 1000); + +describe('agent phase policy', () => { + it.each(['planning', 'questions'] as const)('%s exposes only non-mutating built-in tools', phase => { + const policy = createPhasePolicy(request(phase)); + expect(policy).toMatchObject({ phase, worktree: 'read-only', tools: ['read', 'list', 'search'], web: false, mcp: false }); + for (const tool of ['write', 'edit', 'runner-command'] as const) + expect(() => assertAgentTool(policy, tool)).toThrow(`forbidden during ${phase}`); + }); + + it('review dispatches only one exact approved argv without granting a shell tool', () => { + const captured = request('review'), policy = createPhasePolicy(captured), execute = vi.fn(argv => argv.join(' ')); + expect(policy.tools).toEqual(['read', 'list', 'search', 'runner-command']); + expect(dispatchApprovedCommand(policy, ['npm', 'test'], execute)).toBe('npm test'); + expect(execute).toHaveBeenCalledWith(['npm', 'test']); + expect(() => dispatchApprovedCommand(policy, ['npm', 'test', '--changed'], execute)).toThrow('not approved exactly'); + expect(() => dispatchApprovedCommand(policy, ['sh', '-c', 'npm test'], execute)).toThrow('not approved exactly'); + expect(() => assertAgentTool({ ...policy }, 'read')).toThrow('trusted policy builder'); + }); + + it.each(['execute', 'fix'] as const)('%s permits edits and exact runner commands', phase => { + const policy = createPhasePolicy(request(phase)); + expect(policy.worktree).toBe('read-write'); + for (const tool of ['read', 'list', 'search', 'write', 'edit', 'runner-command'] as const) + expect(() => assertAgentTool(policy, tool)).not.toThrow(); + expect(dispatchApprovedCommand(policy, ['npm', 'test'], argv => argv)).toEqual(['npm', 'test']); + }); + + it('builds Claude and Codex controls with web, MCP and direct shell disabled', () => { + const readonly = createPhasePolicy(request('planning')); + const claude = createClaudeCommand(readonly, 'Inspect the schema.'); + expect(claude).toContain('--strict-mcp-config'); + expect(claude).toContain('{"mcpServers":{}}'); + expect(claude).toContain('Read,Glob,Grep'); + expect(claude).toContain('Bash,WebFetch,WebSearch,NotebookEdit'); + expect(claude).not.toContain('Edit'); + expect(codexBaseArguments(readonly)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"', + '--config', 'mcp_servers={}', '--ask-for-approval', 'never']); + }); +}); From 84159b7c3536c65ef656b26d092a411b4c9dc619 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 11:06:39 -0700 Subject: [PATCH 02/20] Enforce invocation-scoped agent policy --- agents/container/profile.ts | 15 ++-- agents/network/network.ts | 77 +++++++++++------- agents/policy.ts | 56 ++++++++++++- test/agent-container.test.ts | 154 ++++++++++++++++------------------- test/agent-network.test.ts | 34 +++++++- test/agent-policy.test.ts | 12 ++- 6 files changed, 216 insertions(+), 132 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 50ea162..8913bf9 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -7,7 +7,7 @@ import { assertCapturedInvocation, type InvocationInput, type Phase } from '../c import { assertBuiltAgentImage } from './image.ts'; import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts'; import { assertVendorNetwork, type VendorNetwork } from '../network/network.ts'; -import { assertPhasePolicy, type PhasePolicy } from '../policy.ts'; +import { assertAgentCommand, assertPhasePolicy, type AgentCommand, type PhasePolicy } from '../policy.ts'; export interface ContainerProfile { readonly name: string; readonly args: readonly string[]; @@ -26,7 +26,7 @@ export interface ProfileOptions { readonly invocation: InvocationInput; readonly filesystems: TaskFilesystems; readonly inputDirectory: string; - readonly command: readonly string[]; + readonly command: AgentCommand; readonly imageId: string; readonly codexAuthFile?: string; readonly claudeToken?: string; @@ -116,7 +116,7 @@ export function assertContainerProfile(profile: ContainerProfile): void { const expected = identities.get(profile); if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); assertTaskFilesystems(expected.filesystems, expected.clone); - assertVendorNetwork(expected.network, profile.vendor); + assertVendorNetwork(expected.network, expected.invocation, profile.name); assertPhasePolicy(expected.policy, expected.invocation); const actual = captureInput(expected.inputDirectory); if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) @@ -159,14 +159,13 @@ 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); - assertVendorNetwork(options.network, invocation.vendor); + assertVendorNetwork(options.network, invocation); assertPhasePolicy(options.policy, invocation); + const command = assertAgentCommand(options.command, options.policy); const sourceInput = captureInput(options.inputDirectory); if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); @@ -220,12 +219,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); - args.push(options.imageId, ...options.command); + args.push(options.imageId, ...command); const capturedFilesystems = filesystems; const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile, - command: Object.freeze([...options.command]), ownershipId, network: options.network, policy: options.policy }); + command: Object.freeze([...command]), ownershipId, network: options.network, policy: options.policy }); identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema, auth: authIdentity, cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone, diff --git a/agents/network/network.ts b/agents/network/network.ts index 0e6fbe2..3ea6286 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -14,7 +14,8 @@ export interface VendorNetwork { readonly proxyUrl: string; readonly vendor: InvocationInput['vendor']; } -interface NetworkIdentity { readonly allocationId: string; readonly imageId: string } +interface NetworkIdentity { readonly allocationId: string; readonly imageId: string; readonly invocation: InvocationInput; + readonly subnet: string } const identities = new WeakMap(); const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); const deadline = (timeoutMs: number) => { @@ -30,7 +31,7 @@ const docker = (args: readonly string[], timeout: number) => execFileSync('docke encoding: 'utf8', timeout, killSignal: 'SIGKILL', env: environment(), stdio: ['ignore', 'pipe', 'pipe'], }).trim(); const absent = (result: ReturnType) => result.status !== 0 && !result.error - && /No such (?:object|container|network)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); + && /(?:No such (?:object|container|network)|network .* not found)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); const remove = (args: readonly string[], inspect: readonly string[], remaining: () => number, kind: string, allocationId: string) => { const before = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', @@ -51,23 +52,56 @@ const remove = (args: readonly string[], inspect: readonly string[], remaining: if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`); }; -export function assertVendorNetwork(network: VendorNetwork, vendor?: InvocationInput['vendor']): void { +export function assertVendorNetwork(network: VendorNetwork, invocation?: InvocationInput, agentName?: string): void { const identity = identities.get(network); if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); - if (vendor && network.vendor !== vendor) throw new Error('Vendor network does not match the invocation vendor.'); + if (invocation && (identity.invocation !== invocation || network.vendor !== invocation.vendor)) + throw new Error('Vendor network does not belong to this invocation.'); assertBuiltAgentImage(identity.imageId); + const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], 30_000))[0] as + { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record; Env?: string[]; + Entrypoint?: string[] | null; Cmd?: string[] | null }; + HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; + SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number }; + NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], 30_000))[0] as + { Internal?: boolean; Driver?: string; Labels?: Record; IPAM?: { Config?: Array<{ Subnet?: string }> }; + Containers?: Record } | undefined; + const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); + const endpoints = Object.values(inspectedNetwork?.Containers ?? {}).map(value => value.Name).sort(); + const allowedEndpoints = [network.proxyContainer, ...(agentName ? [agentName] : [])]; + if (!inspect?.State?.Running || inspect.Config?.Image !== identity.imageId || inspect.Config?.User !== '10001:10001' + || inspect.Config?.Labels?.['io.codeboost.egress'] !== identity.allocationId || !inspect.HostConfig?.ReadonlyRootfs + || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1 + || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') + || inspect.HostConfig.PidsLimit !== 64 || inspect.HostConfig.Memory !== 64 * 1024 * 1024 + || inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000 + || JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length + || inspect.Config?.Entrypoint?.[0] !== 'node' + || JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs']) + || inspect.Config.Env?.filter(value => value.startsWith('CODEBOOST_ALLOWED_HOSTS=')).length !== 1 + || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[network.vendor].join(',')}`) + || !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge' + || inspectedNetwork.Labels?.['io.codeboost.egress'] !== identity.allocationId + || inspectedNetwork.IPAM?.Config?.length !== 1 || inspectedNetwork.IPAM.Config[0]?.Subnet !== identity.subnet + || !endpoints.includes(network.proxyContainer) || endpoints.some(name => !name || !allowedEndpoints.includes(name))) + throw new Error('Vendor network or proxy changed after allocation.'); } -export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: string, +export function createVendorNetwork(invocation: InvocationInput, imageId: string, timeoutMs = 60_000): VendorNetwork { assertBuiltAgentImage(imageId); + const vendor = invocation.vendor; const remaining = deadline(timeoutMs), allocationId = randomUUID(); const name = `codeboost-egress-${vendor}-${randomUUID()}`; const proxyContainer = `codeboost-proxy-${vendor}-${randomUUID()}`; + const subnetSeed = randomUUID().replaceAll('-', ''); + const subnet = `10.254.${parseInt(subnetSeed.slice(0, 2), 16)}.${parseInt(subnetSeed.slice(2, 4), 16) & 0xf8}/29`; let networkPlanned = false, proxyPlanned = false; try { networkPlanned = true; - docker(['network', 'create', '--internal', '--driver', 'bridge', + docker(['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, '--label', `io.codeboost.egress=${allocationId}`, name], remaining()); proxyPlanned = true; docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', @@ -82,29 +116,10 @@ export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: "socket.once('connect',()=>{socket.destroy();process.exit(0)});", "socket.once('error',()=>{socket.destroy();if(++attempts===50)process.exit(1);setTimeout(check,20)})};check();", ].join('')], remaining()); - const inspect = JSON.parse(docker(['container', 'inspect', proxyContainer], remaining()))[0] as - { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record; Env?: string[] }; - HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; - SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number }; - NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; - const inspectedNetwork = JSON.parse(docker(['network', 'inspect', name], remaining()))[0] as - { Internal?: boolean; Driver?: string; Labels?: Record } | undefined; - const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); - if (!inspect?.State?.Running || inspect.Config?.Image !== imageId || inspect.Config?.User !== '10001:10001' - || inspect.Config?.Labels?.['io.codeboost.egress'] !== allocationId || !inspect.HostConfig?.ReadonlyRootfs - || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') - || (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1 - || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') - || inspect.HostConfig.Memory !== 64 * 1024 * 1024 - || inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000 - || JSON.stringify(networks) !== JSON.stringify(['bridge', name].sort()) - || inspect.Mounts?.length || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`) - || !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge' - || inspectedNetwork.Labels?.['io.codeboost.egress'] !== allocationId) - throw new Error('Vendor proxy does not match its pinned isolation profile.'); - remaining(); const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor }); - identities.set(network, Object.freeze({ allocationId, imageId })); + identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet })); + assertVendorNetwork(network, invocation); + remaining(); return network; } catch (error) { const failures: unknown[] = []; @@ -118,8 +133,10 @@ export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: } export function removeVendorNetwork(network: VendorNetwork): void { - assertVendorNetwork(network); - const allocationId = identities.get(network)!.allocationId; + const identity = identities.get(network); + if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); + assertBuiltAgentImage(identity.imageId); + const allocationId = identity.allocationId; const remaining = deadline(30_000), failures: unknown[] = []; try { remove(['rm', '--force', network.proxyContainer], ['container', 'inspect', network.proxyContainer], remaining, 'vendor proxy', allocationId); } catch (error) { failures.push(error); } diff --git a/agents/policy.ts b/agents/policy.ts index b60aa54..edd2442 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -9,8 +9,22 @@ export interface PhasePolicy { readonly web: false; readonly mcp: false; } +export interface AgentCommand { readonly argv: readonly string[] } interface PolicyIdentity { readonly invocation: InvocationInput } const identities = new WeakMap(); +const commands = new WeakMap(); + +const command = (policy: PhasePolicy, argv: readonly string[]): AgentCommand => { + assertPhasePolicy(policy); + const value = Object.freeze({ argv: Object.freeze([...argv]) }); + commands.set(value, policy); + return value; +}; + +export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy): readonly string[] { + if (commands.get(value) !== policy) throw new Error('Container command was not generated for this phase policy.'); + return value.argv; +} export function createPhasePolicy(invocation: InvocationInput): PhasePolicy { const writable = invocation.phase === 'execute' || invocation.phase === 'fix'; @@ -43,19 +57,53 @@ export function dispatchApprovedCommand(policy: PhasePolicy, argv: readonly s return execute(Object.freeze([...argv])); } -export function createClaudeCommand(policy: PhasePolicy, prompt: string): readonly string[] { +export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentCommand { if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.'); assertPhasePolicy(policy); const writable = policy.worktree === 'read-write'; const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep'; - return Object.freeze(['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config', + return command(policy, ['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--no-chrome', '--permission-prompts', 'none', - '--permission-mode', writable ? 'acceptEdits' : 'plan', '--allowedTools', allowed, + '--permission-mode', writable ? 'acceptEdits' : 'plan', '--tools', allowed, '--allowedTools', allowed, '--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir', '/run/codeboost-input']); } export function codexBaseArguments(policy: PhasePolicy): readonly string[] { assertPhasePolicy(policy); return Object.freeze(['codex', '--strict-config', '--config', 'web_search="disabled"', - '--config', 'mcp_servers={}', '--ask-for-approval', 'never']); + '--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']); +} + +export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCommand { + if (!prompt || prompt.includes('\0')) throw new Error('Codex prompt must be nonempty and contain no NUL.'); + const sandbox = policy.worktree === 'read-write' ? 'workspace-write' : 'read-only'; + return command(policy, [...codexBaseArguments(policy), 'exec', '--sandbox', sandbox, '--skip-git-repo-check', prompt]); +} + +export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | 'persist-write' + | 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker'; + +/** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ +export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { + assertPhasePolicy(policy); + const phase = policy.phase; + const scripts: Record, string> = { + 'phase-worktree': policy.worktree === 'read-write' + ? `set -eu; printf ${phase} > /work/${phase}.txt; test -f /work/${phase}.txt` + : `set -eu; ! touch /work/${phase}.txt 2>/dev/null; test ! -e /work/${phase}.txt`, + 'read-only-isolation': 'set -eu; test "$(id -u)" = 10001; test "$(git status --porcelain)" = ""; ' + + 'test -z "${HOST_SECRET_SENTINEL:-}"; ! touch /work/forbidden; ! touch /usr/bin/forbidden; ' + + 'touch /tmp/allowed "$HOME/allowed"; printf isolated', + 'persist-write': 'set -eu; printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first', + 'persist-read': 'set -eu; test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain', + capacity: 'set -eu; ! dd if=/dev/zero of=/work/overflow bs=1M count=32 2>/dev/null; rm -f /work/overflow; ' + + 'mkdir /work/many; i=0; while touch "/work/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt 2000; done; ' + + 'test "$i" -lt 2000; test "$(find /work/many -type f | wc -l)" -eq "$i"; rm -rf /work/many; printf bounded', + metadata: 'set -eu; ! touch /work/.git/forbidden 2>/dev/null; ! ln /work/.git/HEAD /work/metadata-link 2>/dev/null; ' + + '! mv /work/.git /work/replaced 2>/dev/null; git status --porcelain; printf metadata-safe', + 'must-not-run': 'touch /tmp/command-ran', + 'input-marker': 'set -eu; grep -q codeboost-schema-marker /run/codeboost-input/schema.json; ' + + 'test ! -e /run/codeboost-input/extra.json', + }; + return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index d0776e3..16e13a4 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -3,7 +3,7 @@ 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'; @@ -11,14 +11,15 @@ import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems hasExactOptions, validateContainer } from '../agents/container/run.ts'; import { createTaskClone } from '../git/clone.ts'; import { createVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../agents/network/network.ts'; -import { codexBaseArguments, createClaudeCommand, createPhasePolicy } from '../agents/policy.ts'; +import { createClaudeCommand, createCodexCommand, createIsolationProbeCommand, createPhasePolicy, + type AgentCommand, type IsolationProbe } from '../agents/policy.ts'; const roots: string[] = []; const taskFilesystems: ReturnType[] = []; const containers = new Set(); const profiles: ReturnType[] = []; let imageId = ''; -let vendorNetworks: Record<'claude' | 'codex', VendorNetwork>; +const vendorNetworks: VendorNetwork[] = []; const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const docker = (...args: string[]) => execFileSync('docker', args, { @@ -55,16 +56,23 @@ function invocation(clone: ReturnType, phase: Phase, ven context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', referencedCodeHash: 'code-1', stateVersion: 1 } }); } -const governed = (captured: InvocationInput) => ({ invocation: captured, policy: createPhasePolicy(captured) }); - -function profile(data: ReturnType, phase: Phase, command: string[], options: { +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; } = {}) { const vendor = options.vendor ?? 'codex'; const captured = invocation(data.clone, phase, vendor); - const base = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, - inputDirectory: data.input, command, - imageId, network: vendorNetworks[vendor], + const policy = createPhasePolicy(captured), network = createVendorNetwork(captured, imageId); + vendorNetworks.push(network); + const trustedCommand = typeof command === 'string' ? createIsolationProbeCommand(policy, command) : command(policy); + const base = createContainerProfile({ invocation: captured, policy, network, filesystems: data.filesystems, + inputDirectory: data.input, command: trustedCommand, imageId, codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); profiles.push(base); @@ -73,13 +81,15 @@ function profile(data: ReturnType, phase: Phase, command: string beforeAll(() => { imageId = buildAgentImage(); - vendorNetworks = { claude: createVendorNetwork('claude', imageId), codex: createVendorNetwork('codex', imageId) }; }, 10 * 60_000); +afterEach(() => { + for (const network of vendorNetworks.splice(0).reverse()) removeVendorNetwork(network); +}, 120_000); afterAll(() => { for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); for (const profile of profiles) disposeContainerProfile(profile); - for (const network of Object.values(vendorNetworks).reverse()) removeVendorNetwork(network); + for (const network of vendorNetworks.splice(0).reverse()) removeVendorNetwork(network); for (const root of roots.reverse()) { chmodSync(join(root, 'input'), 0o700); rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); @@ -89,27 +99,15 @@ afterAll(() => { describe('real Docker agent isolation', () => { it.each(['planning', 'questions', 'review', 'execute', 'fix'] as const)( '%s applies its enforced worktree access profile', phase => { - const data = fixture(), writable = phase === 'execute' || phase === 'fix'; - const command = writable - ? ['sh', '-c', `set -eu; printf ${phase} > /work/${phase}.txt; test -f /work/${phase}.txt`] - : ['sh', '-c', `set -eu; ! touch /work/${phase}.txt 2>/dev/null; test ! -e /work/${phase}.txt`]; - expect(runContainer(profile(data, phase, command))).toBe(''); + const data = fixture(); + expect(runContainer(profile(data, phase, 'phase-worktree'))).toBe(''); }, 60_000); it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { const data = fixture(); process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; try { - const output = runContainer(profile(data, 'planning', ['sh', '-c', ['set -eu', - 'test "$(id -u)" = 10001', - 'test "$(git status --porcelain)" = ""', - 'test ! -e "$1"', - 'test -z "${HOST_SECRET_SENTINEL:-}"', - '! touch /work/forbidden', - '! touch /usr/bin/forbidden', - 'touch /tmp/allowed "$HOME/allowed"', - 'printf isolated', - ].join('; '), 'probe', data.source])); + const output = runContainer(profile(data, 'planning', 'read-only-isolation')); expect(output).toBe('isolated'); } finally { delete process.env.HOST_SECRET_SENTINEL; } }, 60_000); @@ -137,43 +135,26 @@ describe('real Docker agent isolation', () => { it('persists execution changes while replacing HOME and scratch for each invocation', () => { const data = fixture(); - expect(runContainer(profile(data, 'execute', ['sh', '-c', - '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); @@ -186,28 +167,26 @@ describe('real Docker agent isolation', () => { it('rejects mixed credentials and unsupported command/profile inputs', () => { const data = fixture(); expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'codex')), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - claudeToken: 'must-not-combine', imageId, network: vendorNetworks.codex })).toThrow('only'); + filesystems: data.filesystems, inputDirectory: data.input, codexAuthFile: data.fakeAuth, + claudeToken: 'must-not-combine', imageId })).toThrow('only'); expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'claude')), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, - network: vendorNetworks.claude })).toThrow('OAuth'); + filesystems: data.filesystems, inputDirectory: data.input, imageId })).toThrow('OAuth'); const claudeProfile = createContainerProfile({ ...governed(invocation(data.clone, 'planning', 'claude')), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, - claudeToken: 'serialization-sentinel', network: vendorNetworks.claude }); + filesystems: data.filesystems, inputDirectory: data.input, imageId, claudeToken: 'serialization-sentinel' }); expect(JSON.stringify(claudeProfile)).not.toContain('serialization-sentinel'); expect(() => createValidatedContainer(claudeProfile)).toThrow('OAuth environment credential'); + const untrusted = governed(invocation(data.clone, 'planning')); + expect(() => createContainerProfile({ ...untrusted, command: { argv: ['true'] }, filesystems: data.filesystems, + inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId })).toThrow('not generated'); expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), - filesystems: data.filesystems, inputDirectory: data.input, command: [], imageId, - network: vendorNetworks.codex })).toThrow('argv'); - expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - imageId: AGENT_IMAGE, network: vendorNetworks.codex })).toThrow('immutable built image ID'); + filesystems: data.filesystems, inputDirectory: data.input, codexAuthFile: data.fakeAuth, + imageId: AGENT_IMAGE })).toThrow('immutable built image ID'); chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); - expect(() => profile(data, 'planning', ['true'])).toThrow('only one bounded'); + expect(() => profile(data, 'planning', 'noop')).toThrow('only one bounded'); }); it('rejects unexpected host mounts and unbounded task volumes after Docker resolves them', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); const imageIndex = valid.args.indexOf(imageId); const extraMountArgs = [...valid.args.slice(0, imageIndex), '--mount', 'type=bind,source=/tmp,target=/unexpected,readonly', ...valid.args.slice(imageIndex)]; @@ -225,21 +204,20 @@ describe('real Docker agent isolation', () => { }, 60_000); it('rejects cloned profiles while sealed snapshots ignore later host changes', () => { - const data = fixture(), valid = profile(data, 'planning', ['sh', '-c', - 'set -eu; grep -q codeboost-schema-marker /run/codeboost-input/schema.json; test ! -e /run/codeboost-input/extra.json']); + const data = fixture(), valid = profile(data, 'planning', 'input-marker'); const forged = Object.freeze({ ...valid, inputDirectory: '/', args: Object.freeze(valid.args.map(value => value.includes(`source=${data.input},`) ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); expect(() => createContainerProfile({ ...governed(invocation(data.clone, 'planning')), - filesystems: { ...data.filesystems }, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - imageId, network: vendorNetworks.codex })).toThrow('trusted allocator'); + filesystems: { ...data.filesystems }, inputDirectory: data.input, codexAuthFile: data.fakeAuth, + imageId })).toThrow('trusted allocator'); const other = fixture(); expect(() => createContainerProfile({ ...governed(invocation(other.clone, 'planning')), - filesystems: data.filesystems, inputDirectory: other.input, command: ['true'], codexAuthFile: other.fakeAuth, - imageId, network: vendorNetworks.codex })).toThrow('do not belong to the invocation clone'); + filesystems: data.filesystems, inputDirectory: other.input, codexAuthFile: other.fakeAuth, + imageId })).toThrow('do not belong to the invocation clone'); writeFileSync(data.fakeAuth, '{"changed":true}'); expect(valid.codexAuthFile).not.toBe(data.fakeAuth); @@ -256,7 +234,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)]; @@ -280,10 +258,11 @@ describe('real Docker agent isolation', () => { it('does not remove an active container when a duplicate attempt name collides', () => { const data = fixture(), captured = invocation(data.clone, 'planning'); - const first = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, - inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId, network: vendorNetworks.codex }); - const duplicate = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, - inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId, network: vendorNetworks.codex }); + const common = governed(captured); + const first = createContainerProfile({ ...common, filesystems: data.filesystems, + inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId }); + const duplicate = createContainerProfile({ ...common, filesystems: data.filesystems, + inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId }); profiles.push(first, duplicate); docker(...first.args); containers.add(first.name); expect(() => createValidatedContainer(duplicate)).toThrow('Container creation failed and cleanup did not settle.'); @@ -411,7 +390,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); @@ -428,7 +407,7 @@ describe('real Docker agent isolation', () => { }, 60_000); it('rejects an unauthorized network before the container can start', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); const args = valid.args.map(value => value.startsWith('--network=') ? '--network=bridge' : value); docker(...args); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); @@ -448,8 +427,20 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); + it('rejects a new endpoint attached to the invocation network before launch', () => { + const data = fixture(), valid = profile(data, 'planning', 'must-not-run'); + const rogue = `codeboost-rogue-${randomUUID()}`; + try { + docker('run', '--detach', '--name', rogue, `--network=${valid.network.name}`, '--entrypoint', 'node', imageId, + '-e', 'setInterval(()=>{},1000)'); + expect(() => createValidatedContainer(valid)).toThrow('network or proxy changed'); + const absent = spawnSync('docker', ['container', 'inspect', valid.name], { encoding: 'utf8' }); + expect(absent.status).not.toBe(0); + } finally { spawnSync('docker', ['rm', '--force', rogue], { stdio: 'ignore' }); } + }, 60_000); + it('creates containers from the captured immutable image rather than its mutable tag', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + const data = fixture(), valid = profile(data, 'planning', 'noop'); expect(valid.expectedImage).toBe(imageId); expect(valid.args).toContain(imageId); expect(valid.args).not.toContain(AGENT_IMAGE); @@ -470,10 +461,9 @@ describe('real Docker agent isolation', () => { it('runs the authenticated Codex startup path with isolated writable state', () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); - const policy = createPhasePolicy(invocation(data.clone, 'planning', 'codex')); - const command = [...codexBaseArguments(policy), 'exec', '--sandbox', 'read-only', '--skip-git-repo-check', - 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.']; - const authProfile = profile(data, 'planning', command, { authProbe: true, codexAuthFile: authFile }); + const authProfile = profile(data, 'planning', policy => createCodexCommand(policy, + 'Reply only with this exact marker: codeboost-schema-marker'), + { authProbe: true, codexAuthFile: authFile }); docker(...authProfile.args); containers.add(authProfile.name); const output = docker('start', '--attach', authProfile.name); docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); @@ -483,10 +473,8 @@ describe('real Docker agent isolation', () => { it('runs the authenticated Claude startup path with only its OAuth token', () => { const data = fixture(), token = process.env.CLAUDE_CODE_OAUTH_TOKEN; if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); - const policy = createPhasePolicy(invocation(data.clone, 'planning', 'claude')); - const command = createClaudeCommand(policy, - 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, without quotes or Markdown formatting.'); - const authProfile = profile(data, 'planning', [...command], + const authProfile = profile(data, 'planning', policy => createClaudeCommand(policy, + 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, without quotes or Markdown formatting.'), { vendor: 'claude', authProbe: true, claudeToken: token }); const result = execFileSync('docker', authProfile.args, { encoding: 'utf8', timeout: 60_000, env: { PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, CLAUDE_CODE_OAUTH_TOKEN: token } }); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index ab2361b..003cb14 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -1,9 +1,17 @@ import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildAgentImage } from '../agents/container/image.ts'; -import { createVendorNetwork, removeVendorNetwork, VENDOR_HOSTS, type VendorNetwork } from '../agents/network/network.ts'; +import { assertVendorNetwork, createVendorNetwork, removeVendorNetwork, VENDOR_HOSTS, + type VendorNetwork } from '../agents/network/network.ts'; +import { captureInvocation } from '../agents/contract.ts'; let imageId = '', network: VendorNetwork; +const invocation = captureInvocation({ + clone: { id: 'clone-network', taskId: 'task-network', directory: '/tmp/network', head: 'a'.repeat(40) }, + vendor: 'claude', phase: 'planning', approvedArgv: [], deadline: Date.now() + 60_000, attemptId: 'network-probe', + context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, +}); const docker = (...args: string[]) => execFileSync('docker', args, { encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], }).trim(); @@ -14,7 +22,7 @@ const curl = (url: string, direct = false) => spawnSync('docker', ['run', '--rm' beforeAll(() => { imageId = buildAgentImage(); - network = createVendorNetwork('claude', imageId); + network = createVendorNetwork(invocation, imageId); }, 10 * 60_000); afterAll(() => removeVendorNetwork(network), 60_000); @@ -42,7 +50,27 @@ describe('vendor-only egress', () => { }, 60_000); it('rejects a copied network capability', () => { - expect(() => createVendorNetwork('claude', imageId, 0)).toThrow('positive integer'); + expect(() => createVendorNetwork(invocation, imageId, 0)).toThrow('positive integer'); expect(() => removeVendorNetwork({ ...network })).toThrow('trusted network builder'); + const otherInvocation = captureInvocation({ ...invocation, attemptId: 'other-network-probe', + deadline: Date.now() + 60_000 }); + expect(() => assertVendorNetwork(network, otherInvocation)).toThrow('does not belong'); }); + + it('keeps concurrent invocations on separate internal networks', () => { + const otherInvocation = captureInvocation({ ...invocation, attemptId: 'concurrent-network-probe', + deadline: Date.now() + 60_000 }); + const other = createVendorNetwork(otherInvocation, imageId), peer = `codeboost-peer-${randomUUID()}`; + try { + docker('run', '--detach', '--name', peer, `--network=${network.name}`, '--network-alias', 'codeboost-peer', + '--entrypoint', 'node', imageId, '-e', "require('node:net').createServer(()=>{}).listen(4567,'0.0.0.0');setInterval(()=>{},1000)"); + const result = spawnSync('docker', ['run', '--rm', `--network=${other.name}`, '--entrypoint', 'node', imageId, + '-e', "const s=require('node:net').connect(4567,'codeboost-peer');s.on('connect',()=>process.exit(0));s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(2),3000)"], + { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }); + expect(result.status).not.toBe(0); + } finally { + spawnSync('docker', ['rm', '--force', peer], { stdio: 'ignore' }); + removeVendorNetwork(other); + } + }, 60_000); }); diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts index 32e94b3..dfc6186 100644 --- a/test/agent-policy.test.ts +++ b/test/agent-policy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; -import { assertAgentTool, codexBaseArguments, createClaudeCommand, createPhasePolicy, - dispatchApprovedCommand } from '../agents/policy.ts'; +import { assertAgentCommand, assertAgentTool, codexBaseArguments, createClaudeCommand, createCodexCommand, + createPhasePolicy, dispatchApprovedCommand } from '../agents/policy.ts'; const request = (phase: Phase): InvocationInput => captureInvocation({ clone: { id: 'clone-1', taskId: 'task-1', directory: '/tmp/task', head: 'a'.repeat(40) }, @@ -38,13 +38,17 @@ describe('agent phase policy', () => { it('builds Claude and Codex controls with web, MCP and direct shell disabled', () => { const readonly = createPhasePolicy(request('planning')); - const claude = createClaudeCommand(readonly, 'Inspect the schema.'); + const claude = createClaudeCommand(readonly, 'Inspect the schema.').argv; expect(claude).toContain('--strict-mcp-config'); expect(claude).toContain('{"mcpServers":{}}'); + expect(claude).toContain('--tools'); expect(claude).toContain('Read,Glob,Grep'); expect(claude).toContain('Bash,WebFetch,WebSearch,NotebookEdit'); expect(claude).not.toContain('Edit'); expect(codexBaseArguments(readonly)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"', - '--config', 'mcp_servers={}', '--ask-for-approval', 'never']); + '--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']); + const codex = createCodexCommand(readonly, 'Inspect the schema.'); + expect(codex.argv).toContain('features.shell_tool=false'); + expect(() => assertAgentCommand({ argv: codex.argv }, readonly)).toThrow('not generated'); }); }); From 5cbe6d170d1f4117bb9dfa7c27004c15994f7578 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 11:21:41 -0700 Subject: [PATCH 03/20] Bind adapters and proxy checks to invocation --- agents/container/profile.ts | 6 +++--- agents/container/run.ts | 10 +++++----- agents/network/network.ts | 23 ++++++++++++++++++----- agents/policy.ts | 15 +++++++++------ test/agent-container.test.ts | 2 +- test/agent-network.test.ts | 20 ++++++++++++++++++++ test/agent-policy.test.ts | 14 +++++++++----- 7 files changed, 65 insertions(+), 25 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 8913bf9..5466596 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -112,11 +112,11 @@ const captureInput = (directory: string): InputCapture => { }; /** Internal authenticity and host-file revalidation used at every launch boundary. */ -export function assertContainerProfile(profile: ContainerProfile): void { +export function assertContainerProfile(profile: ContainerProfile, timeoutMs = 30_000): void { const expected = identities.get(profile); if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); assertTaskFilesystems(expected.filesystems, expected.clone); - assertVendorNetwork(expected.network, expected.invocation, profile.name); + assertVendorNetwork(expected.network, expected.invocation, profile.name, timeoutMs); assertPhasePolicy(expected.policy, expected.invocation); const actual = captureInput(expected.inputDirectory); if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) @@ -165,7 +165,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil assertTaskFilesystems(filesystems, invocation.clone); assertVendorNetwork(options.network, invocation); assertPhasePolicy(options.policy, invocation); - const command = assertAgentCommand(options.command, options.policy); + const command = assertAgentCommand(options.command, options.policy, invocation.vendor); const sourceInput = captureInput(options.inputDirectory); if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); diff --git a/agents/container/run.ts b/agents/container/run.ts index c07c9ad..b6cae93 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -109,7 +109,7 @@ type Inspect = { /** Validate daemon-resolved configuration before starting an agent. */ export function validateContainer(container: string, profile: ContainerProfile, timeoutMs = 30_000): void { const remaining = createDeadline(timeoutMs); - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); const inspect = JSON.parse(docker(['container', 'inspect', container], { timeoutMs: remaining() }))[0] as Inspect | undefined; if (!inspect) throw new Error('Docker did not return the created container.'); const image = JSON.parse(docker(['image', 'inspect', profile.expectedImage], { timeoutMs: remaining() }))[0] as @@ -248,7 +248,7 @@ export function validateContainer(container: string, profile: ContainerProfile, throw new Error('Credential profiles must not be combined or redirected.'); if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) throw new Error('Credential profiles must not be combined.'); - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); remaining(); } @@ -258,7 +258,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 }); } @@ -269,7 +269,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) { @@ -285,7 +285,7 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, const container = createValidatedContainer(profile, remaining(), secrets); let failure: unknown; try { - assertContainerProfile(profile); + assertContainerProfile(profile, remaining()); const output = docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); remaining(); return output; diff --git a/agents/network/network.ts b/agents/network/network.ts index 3ea6286..4a8c37e 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -52,19 +52,22 @@ const remove = (args: readonly string[], inspect: readonly string[], remaining: if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`); }; -export function assertVendorNetwork(network: VendorNetwork, invocation?: InvocationInput, agentName?: string): void { +const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInput | undefined, + agentName: string | undefined, remaining: () => number): void => { const identity = identities.get(network); if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); if (invocation && (identity.invocation !== invocation || network.vendor !== invocation.vendor)) throw new Error('Vendor network does not belong to this invocation.'); assertBuiltAgentImage(identity.imageId); - const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], 30_000))[0] as + const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], remaining()))[0] as { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record; Env?: string[]; Entrypoint?: string[] | null; Cmd?: string[] | null }; HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; - SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number }; + SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number; + NetworkMode?: string; PidMode?: string; IpcMode?: string; UTSMode?: string; UsernsMode?: string; + CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null }; NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; - const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], 30_000))[0] as + const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], remaining()))[0] as { Internal?: boolean; Driver?: string; Labels?: Record; IPAM?: { Config?: Array<{ Subnet?: string }> }; Containers?: Record } | undefined; const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); @@ -77,6 +80,10 @@ export function assertVendorNetwork(network: VendorNetwork, invocation?: Invocat || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') || inspect.HostConfig.PidsLimit !== 64 || inspect.HostConfig.Memory !== 64 * 1024 * 1024 || inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000 + || inspect.HostConfig.NetworkMode !== network.name || inspect.HostConfig.PidMode !== '' + || inspect.HostConfig.IpcMode !== 'private' || inspect.HostConfig.UTSMode !== '' + || inspect.HostConfig.UsernsMode !== '' || inspect.HostConfig.CgroupnsMode !== 'private' + || (inspect.HostConfig.Devices?.length ?? 0) !== 0 || (inspect.HostConfig.DeviceRequests?.length ?? 0) !== 0 || JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length || inspect.Config?.Entrypoint?.[0] !== 'node' || JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs']) @@ -87,6 +94,12 @@ export function assertVendorNetwork(network: VendorNetwork, invocation?: Invocat || inspectedNetwork.IPAM?.Config?.length !== 1 || inspectedNetwork.IPAM.Config[0]?.Subnet !== identity.subnet || !endpoints.includes(network.proxyContainer) || endpoints.some(name => !name || !allowedEndpoints.includes(name))) throw new Error('Vendor network or proxy changed after allocation.'); + remaining(); +}; + +export function assertVendorNetwork(network: VendorNetwork, invocation?: InvocationInput, agentName?: string, + timeoutMs = 30_000): void { + validateVendorNetwork(network, invocation, agentName, deadline(timeoutMs)); } export function createVendorNetwork(invocation: InvocationInput, imageId: string, @@ -118,7 +131,7 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string ].join('')], remaining()); const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor }); identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet })); - assertVendorNetwork(network, invocation); + validateVendorNetwork(network, invocation, undefined, remaining); remaining(); return network; } catch (error) { diff --git a/agents/policy.ts b/agents/policy.ts index edd2442..826c856 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -12,17 +12,20 @@ export interface PhasePolicy { export interface AgentCommand { readonly argv: readonly string[] } interface PolicyIdentity { readonly invocation: InvocationInput } const identities = new WeakMap(); -const commands = new WeakMap(); +const commands = new WeakMap(); const command = (policy: PhasePolicy, argv: readonly string[]): AgentCommand => { assertPhasePolicy(policy); const value = Object.freeze({ argv: Object.freeze([...argv]) }); - commands.set(value, policy); + commands.set(value, Object.freeze({ policy, vendor: assertPhasePolicy(policy).vendor })); return value; }; -export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy): readonly string[] { - if (commands.get(value) !== policy) throw new Error('Container command was not generated for this phase policy.'); +export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy, + vendor?: InvocationInput['vendor']): readonly string[] { + const identity = commands.get(value); + if (identity?.policy !== policy || (vendor && identity.vendor !== vendor)) + throw new Error('Container command was not generated for this phase policy and vendor.'); return value.argv; } @@ -59,7 +62,7 @@ export function dispatchApprovedCommand(policy: PhasePolicy, argv: readonly s export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentCommand { if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.'); - assertPhasePolicy(policy); + if (assertPhasePolicy(policy).vendor !== 'claude') throw new Error('Claude command requires a Claude invocation policy.'); const writable = policy.worktree === 'read-write'; const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep'; return command(policy, ['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config', @@ -69,7 +72,7 @@ export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentC } export function codexBaseArguments(policy: PhasePolicy): readonly string[] { - assertPhasePolicy(policy); + if (assertPhasePolicy(policy).vendor !== 'codex') throw new Error('Codex command requires a Codex invocation policy.'); return Object.freeze(['codex', '--strict-config', '--config', 'web_search="disabled"', '--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']); } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 16e13a4..1aef99a 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -183,7 +183,7 @@ describe('real Docker agent isolation', () => { imageId: AGENT_IMAGE })).toThrow('immutable built image ID'); chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => profile(data, 'planning', 'noop')).toThrow('only one bounded'); - }); + }, 60_000); it('rejects unexpected host mounts and unbounded task volumes after Docker resolves them', () => { const data = fixture(), valid = profile(data, 'planning', 'noop'); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index 003cb14..09ffc4e 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -51,6 +51,7 @@ describe('vendor-only egress', () => { it('rejects a copied network capability', () => { expect(() => createVendorNetwork(invocation, imageId, 0)).toThrow('positive integer'); + expect(() => assertVendorNetwork(network, invocation, undefined, 0)).toThrow('positive integer'); expect(() => removeVendorNetwork({ ...network })).toThrow('trusted network builder'); const otherInvocation = captureInvocation({ ...invocation, attemptId: 'other-network-probe', deadline: Date.now() + 60_000 }); @@ -73,4 +74,23 @@ describe('vendor-only egress', () => { removeVendorNetwork(other); } }, 60_000); + + it('rejects a proxy replaced with a host namespace before launch', () => { + const replacementInvocation = captureInvocation({ ...invocation, attemptId: 'mutated-proxy-probe', + deadline: Date.now() + 60_000 }); + const replacement = createVendorNetwork(replacementInvocation, imageId); + const inspected = JSON.parse(docker('container', 'inspect', replacement.proxyContainer))[0] as + { Config: { Labels: Record } }; + const allocation = inspected.Config.Labels['io.codeboost.egress']; + try { + docker('rm', '--force', replacement.proxyContainer); + docker('run', '--detach', '--name', replacement.proxyContainer, '--read-only', '--user', '10001:10001', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', + '--cpus=.25', '--pid=host', '--network', replacement.name, '--network-alias', 'codeboost-proxy', + '--label', `io.codeboost.egress=${allocation}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS.claude.join(',')}`, + '--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs'); + docker('network', 'connect', 'bridge', replacement.proxyContainer); + expect(() => assertVendorNetwork(replacement, replacementInvocation)).toThrow('network or proxy changed'); + } finally { removeVendorNetwork(replacement); } + }, 60_000); }); diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts index dfc6186..e4e2939 100644 --- a/test/agent-policy.test.ts +++ b/test/agent-policy.test.ts @@ -3,9 +3,9 @@ import { captureInvocation, type InvocationInput, type Phase } from '../agents/c import { assertAgentCommand, assertAgentTool, codexBaseArguments, createClaudeCommand, createCodexCommand, createPhasePolicy, dispatchApprovedCommand } from '../agents/policy.ts'; -const request = (phase: Phase): InvocationInput => captureInvocation({ +const request = (phase: Phase, vendor: 'claude' | 'codex' = 'claude'): InvocationInput => captureInvocation({ clone: { id: 'clone-1', taskId: 'task-1', directory: '/tmp/task', head: 'a'.repeat(40) }, - vendor: 'claude', phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['npm', 'test']], + vendor, phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['npm', 'test']], deadline: 2000, attemptId: `attempt-${phase}`, context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, }, 1000); @@ -45,10 +45,14 @@ describe('agent phase policy', () => { expect(claude).toContain('Read,Glob,Grep'); expect(claude).toContain('Bash,WebFetch,WebSearch,NotebookEdit'); expect(claude).not.toContain('Edit'); - expect(codexBaseArguments(readonly)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"', + const codexPolicy = createPhasePolicy(request('planning', 'codex')); + expect(codexBaseArguments(codexPolicy)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"', '--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']); - const codex = createCodexCommand(readonly, 'Inspect the schema.'); + const codex = createCodexCommand(codexPolicy, 'Inspect the schema.'); expect(codex.argv).toContain('features.shell_tool=false'); - expect(() => assertAgentCommand({ argv: codex.argv }, readonly)).toThrow('not generated'); + expect(() => assertAgentCommand({ argv: codex.argv }, codexPolicy)).toThrow('not generated'); + expect(() => assertAgentCommand(codex, codexPolicy, 'claude')).toThrow('vendor'); + expect(() => createClaudeCommand(codexPolicy, 'Wrong vendor.')).toThrow('Claude invocation'); + expect(() => createCodexCommand(readonly, 'Wrong vendor.')).toThrow('Codex invocation'); }); }); From 22faf3ad4976532f21e9c5c04f9f2565066e3caa Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 11:47:04 -0700 Subject: [PATCH 04/20] Block agent DNS and harden proxy validation --- agents/container/profile.ts | 3 ++- agents/container/run.ts | 4 +++- agents/network/network.ts | 23 ++++++++++++++++------- test/agent-container.test.ts | 5 +++++ test/agent-network.test.ts | 17 +++++++++++++++-- test/questions.test.ts | 4 ++-- 6 files changed, 43 insertions(+), 13 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 5466596..e4e664a 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -201,7 +201,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', '--cpus=1', '--shm-size=16m', '--ipc=private', '--cgroupns=private', - `--network=${options.network.name}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + `--network=${options.network.name}`, '--dns=127.0.0.1', '--env', 'HOME=/home/codeboost', + '--env', `CODEBOOST_PHASE=${invocation.phase}`, '--label', `io.codeboost.invocation=${ownershipId}`, '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', '--env', `HTTPS_PROXY=${options.network.proxyUrl}`, '--env', `HTTP_PROXY=${options.network.proxyUrl}`, diff --git a/agents/container/run.ts b/agents/container/run.ts index b6cae93..1dae5e6 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -101,7 +101,7 @@ 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[] }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; NetworkSettings: { Networks: Record }; }; @@ -147,6 +147,8 @@ 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 (JSON.stringify(Object.keys(inspect.NetworkSettings.Networks)) !== JSON.stringify([profile.network.name])) throw new Error('Container network attachment changed.'); const tmpfs = host.Tmpfs ?? {}; diff --git a/agents/network/network.ts b/agents/network/network.ts index 4a8c37e..b82736a 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -15,7 +15,7 @@ export interface VendorNetwork { readonly vendor: InvocationInput['vendor']; } interface NetworkIdentity { readonly allocationId: string; readonly imageId: string; readonly invocation: InvocationInput; - readonly subnet: string } + readonly subnet: string; readonly proxyIp: string } const identities = new WeakMap(); const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); const deadline = (timeoutMs: number) => { @@ -66,13 +66,17 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number; NetworkMode?: string; PidMode?: string; IpcMode?: string; UTSMode?: string; UsernsMode?: string; CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null }; - NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + const image = JSON.parse(docker(['image', 'inspect', identity.imageId], remaining()))[0] as + { Config?: { Env?: string[] } } | undefined; const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], remaining()))[0] as { Internal?: boolean; Driver?: string; Labels?: Record; IPAM?: { Config?: Array<{ Subnet?: string }> }; Containers?: Record } | undefined; const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); const endpoints = Object.values(inspectedNetwork?.Containers ?? {}).map(value => value.Name).sort(); const allowedEndpoints = [network.proxyContainer, ...(agentName ? [agentName] : [])]; + const expectedEnvironment = [...(image?.Config?.Env ?? []), + `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[network.vendor].join(',')}`].sort(); if (!inspect?.State?.Running || inspect.Config?.Image !== identity.imageId || inspect.Config?.User !== '10001:10001' || inspect.Config?.Labels?.['io.codeboost.egress'] !== identity.allocationId || !inspect.HostConfig?.ReadonlyRootfs || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') @@ -85,10 +89,10 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp || inspect.HostConfig.UsernsMode !== '' || inspect.HostConfig.CgroupnsMode !== 'private' || (inspect.HostConfig.Devices?.length ?? 0) !== 0 || (inspect.HostConfig.DeviceRequests?.length ?? 0) !== 0 || JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length - || inspect.Config?.Entrypoint?.[0] !== 'node' + || JSON.stringify(inspect.Config?.Entrypoint) !== JSON.stringify(['node']) || JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs']) - || inspect.Config.Env?.filter(value => value.startsWith('CODEBOOST_ALLOWED_HOSTS=')).length !== 1 - || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[network.vendor].join(',')}`) + || JSON.stringify([...(inspect.Config.Env ?? [])].sort()) !== JSON.stringify(expectedEnvironment) + || inspect.NetworkSettings?.Networks?.[network.name]?.IPAddress !== identity.proxyIp || !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge' || inspectedNetwork.Labels?.['io.codeboost.egress'] !== identity.allocationId || inspectedNetwork.IPAM?.Config?.length !== 1 || inspectedNetwork.IPAM.Config[0]?.Subnet !== identity.subnet @@ -129,8 +133,13 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string "socket.once('connect',()=>{socket.destroy();process.exit(0)});", "socket.once('error',()=>{socket.destroy();if(++attempts===50)process.exit(1);setTimeout(check,20)})};check();", ].join('')], remaining()); - const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor }); - identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet })); + const proxyInspect = JSON.parse(docker(['container', 'inspect', proxyContainer], remaining()))[0] as + { NetworkSettings?: { Networks?: Record } } | undefined; + const proxyIp = proxyInspect?.NetworkSettings?.Networks?.[name]?.IPAddress; + if (!proxyIp || !/^10\.254\.\d{1,3}\.\d{1,3}$/.test(proxyIp)) + throw new Error('Vendor proxy did not receive its expected internal address.'); + const network = Object.freeze({ name, proxyContainer, proxyUrl: `http://${proxyIp}:3128`, vendor }); + identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet, proxyIp })); validateVendorNetwork(network, invocation, undefined, remaining); remaining(); return network; diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 1aef99a..6bcf5a5 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -415,6 +415,11 @@ describe('real Docker agent isolation', () => { expect(state.State.Status).toBe('created'); docker('rm', '--force', valid.name); containers.delete(valid.name); + const dnsArgs = valid.args.map(value => value === '--dns=127.0.0.1' ? '--dns=8.8.8.8' : value); + docker(...dnsArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('DNS configuration'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + const imageIndex = valid.args.indexOf(imageId); const namespaceArgs = [...valid.args.slice(0, imageIndex), '--uts=host', ...valid.args.slice(imageIndex)]; docker(...namespaceArgs); containers.add(valid.name); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index 09ffc4e..f19b17d 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -49,6 +49,16 @@ describe('vendor-only egress', () => { expect(direct.stdout).toBe('000'); }, 60_000); + it('does not forward arbitrary DNS even when the embedded resolver is addressed directly', () => { + const result = spawnSync('docker', ['run', '--rm', `--network=${network.name}`, '--dns=127.0.0.1', + '--entrypoint', 'node', imageId, '-e', [ + "const dns=require('node:dns');dns.setServers(['127.0.0.11']);", + "dns.resolve4('example.com',(error)=>process.exit(error?0:1));", + 'setTimeout(()=>process.exit(0),3000);', + ].join('')], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }); + expect(result.status).toBe(0); + }, 30_000); + it('rejects a copied network capability', () => { expect(() => createVendorNetwork(invocation, imageId, 0)).toThrow('positive integer'); expect(() => assertVendorNetwork(network, invocation, undefined, 0)).toThrow('positive integer'); @@ -75,7 +85,10 @@ describe('vendor-only egress', () => { } }, 60_000); - it('rejects a proxy replaced with a host namespace before launch', () => { + it.each([ + ['host namespace', ['--pid=host']], + ['extra Node environment', ['--env', 'NODE_OPTIONS=--trace-warnings']], + ] as const)('rejects a proxy replaced with %s before launch', (_label, extra) => { const replacementInvocation = captureInvocation({ ...invocation, attemptId: 'mutated-proxy-probe', deadline: Date.now() + 60_000 }); const replacement = createVendorNetwork(replacementInvocation, imageId); @@ -86,7 +99,7 @@ describe('vendor-only egress', () => { docker('rm', '--force', replacement.proxyContainer); docker('run', '--detach', '--name', replacement.proxyContainer, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', - '--cpus=.25', '--pid=host', '--network', replacement.name, '--network-alias', 'codeboost-proxy', + '--cpus=.25', ...extra, '--network', replacement.name, '--network-alias', 'codeboost-proxy', '--label', `io.codeboost.egress=${allocation}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS.claude.join(',')}`, '--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs'); docker('network', 'connect', 'bridge', replacement.proxyContainer); diff --git a/test/questions.test.ts b/test/questions.test.ts index 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}));} From a5f0c057eab3ee83b035757e109e850f6fa72bf6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 12:11:55 -0700 Subject: [PATCH 05/20] Close remaining network lifecycle gaps --- agents/container/profile.ts | 10 +++++++-- agents/container/run.ts | 32 +++++++++++++++++++++++------ agents/network/network.ts | 18 +++++++++++++--- test/agent-container.test.ts | 40 ++++++++++++++++++++++++++++++------ test/agent-network.test.ts | 3 +++ 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index e4e664a..90bcd5c 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -6,7 +6,7 @@ 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, type VendorNetwork } from '../network/network.ts'; +import { assertVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../network/network.ts'; import { assertAgentCommand, assertPhasePolicy, type AgentCommand, type PhasePolicy } from '../policy.ts'; export interface ContainerProfile { readonly name: string; @@ -51,6 +51,7 @@ interface ProfileIdentity { readonly inputDirectory: string; readonly schema: Fi type InputIdentity = Pick; interface InputCapture extends InputIdentity { readonly content: Buffer } const identities = new WeakMap(); +const claimedNetworks = new WeakSet(); const removeOwnedDirectory = (directory: string) => { if (!lstatSync(directory, { throwIfNoEntry: false })) return; chmodSync(directory, 0o700); @@ -140,7 +141,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); } @@ -164,6 +168,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil assertBuiltAgentImage(options.imageId); assertTaskFilesystems(filesystems, invocation.clone); assertVendorNetwork(options.network, invocation); + if (claimedNetworks.has(options.network)) throw new Error('Vendor network already belongs to another container profile.'); assertPhasePolicy(options.policy, invocation); const command = assertAgentCommand(options.command, options.policy, invocation.vendor); const sourceInput = captureInput(options.inputDirectory); @@ -230,6 +235,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil auth: authIdentity, cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone, deadline: invocation.deadline, network: options.network, policy: options.policy, invocation })); + claimedNetworks.add(options.network); return profile; } catch (error) { try { removeOwnedDirectories(cleanupDirectories); } diff --git a/agents/container/run.ts b/agents/container/run.ts index 1dae5e6..610abba 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -101,9 +101,11 @@ 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; Dns: string[] }; + Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null; Dns: string[]; + DnsOptions: string[]; DnsSearch: string[]; ExtraHosts: string[] | null; + PortBindings: Record | null; PublishAllPorts: boolean }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; - NetworkSettings: { Networks: Record }; + NetworkSettings: { Networks: Record; Ports: Record }; }; /** Validate daemon-resolved configuration before starting an agent. */ @@ -149,6 +151,10 @@ export function validateContainer(container: string, profile: ContainerProfile, throw new Error('Container daemon configuration is missing required lockdown.'); if (JSON.stringify(host.Dns) !== JSON.stringify(['127.0.0.1'])) throw new Error('Container DNS configuration changed.'); + if (host.DnsOptions.length || host.DnsSearch.length || (host.ExtraHosts?.length ?? 0) + || Object.keys(host.PortBindings ?? {}).length || host.PublishAllPorts + || Object.keys(inspect.NetworkSettings.Ports ?? {}).length) + throw new Error('Container host or port configuration changed.'); if (JSON.stringify(Object.keys(inspect.NetworkSettings.Networks)) !== JSON.stringify([profile.network.name])) throw new Error('Container network attachment changed.'); const tmpfs = host.Tmpfs ?? {}; @@ -281,14 +287,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, remaining()); - const output = docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); + validateSecrets(profile, secrets); + validateContainer(profile.name, profile, remaining()); + const output = docker(['start', '--attach', profile.name], { timeoutMs: remaining(), secrets }); remaining(); return output; } @@ -301,3 +307,17 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, } } } + +export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, + secrets: Readonly> = {}): string { + const remaining = createDeadline(timeoutMs); + createValidatedContainer(profile, remaining(), secrets); + let startBudget: number; + try { startBudget = remaining(); } + catch (error) { + try { removeContainerOrThrow(profile); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Agent deadline and cleanup both failed.'); } + throw error; + } + return startValidatedContainer(profile, startBudget, secrets); +} diff --git a/agents/network/network.ts b/agents/network/network.ts index b82736a..0cda964 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -17,6 +17,7 @@ export interface VendorNetwork { interface NetworkIdentity { readonly allocationId: string; readonly imageId: string; readonly invocation: InvocationInput; readonly subnet: string; readonly proxyIp: string } const identities = new WeakMap(); +const removedNetworks = new WeakSet(); const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); const deadline = (timeoutMs: number) => { if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Network deadline must be a positive integer.'); @@ -65,8 +66,11 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number; NetworkMode?: string; PidMode?: string; IpcMode?: string; UTSMode?: string; UsernsMode?: string; - CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null }; - NetworkSettings?: { Networks?: Record }; Mounts?: unknown[] } | undefined; + CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null; + Dns?: string[]; DnsOptions?: string[]; DnsSearch?: string[]; ExtraHosts?: string[] | null; + PortBindings?: Record | null; PublishAllPorts?: boolean }; + NetworkSettings?: { Networks?: Record; Ports?: Record }; + Mounts?: unknown[] } | undefined; const image = JSON.parse(docker(['image', 'inspect', identity.imageId], remaining()))[0] as { Config?: { Env?: string[] } } | undefined; const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], remaining()))[0] as @@ -88,6 +92,10 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp || inspect.HostConfig.IpcMode !== 'private' || inspect.HostConfig.UTSMode !== '' || inspect.HostConfig.UsernsMode !== '' || inspect.HostConfig.CgroupnsMode !== 'private' || (inspect.HostConfig.Devices?.length ?? 0) !== 0 || (inspect.HostConfig.DeviceRequests?.length ?? 0) !== 0 + || (inspect.HostConfig.Dns?.length ?? 0) !== 0 || (inspect.HostConfig.DnsOptions?.length ?? 0) !== 0 + || (inspect.HostConfig.DnsSearch?.length ?? 0) !== 0 || (inspect.HostConfig.ExtraHosts?.length ?? 0) !== 0 + || Object.keys(inspect.HostConfig.PortBindings ?? {}).length !== 0 || inspect.HostConfig.PublishAllPorts + || Object.keys(inspect.NetworkSettings?.Ports ?? {}).length !== 0 || JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length || JSON.stringify(inspect.Config?.Entrypoint) !== JSON.stringify(['node']) || JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs']) @@ -156,7 +164,10 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string export function removeVendorNetwork(network: VendorNetwork): void { const identity = identities.get(network); - if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); + if (!identity) { + if (removedNetworks.has(network)) return; + throw new Error('Vendor network was not created by the trusted network builder.'); + } assertBuiltAgentImage(identity.imageId); const allocationId = identity.allocationId; const remaining = deadline(30_000), failures: unknown[] = []; @@ -166,4 +177,5 @@ export function removeVendorNetwork(network: VendorNetwork): void { remaining, 'vendor network', allocationId); } catch (error) { failures.push(error); } if (failures.length) throw new AggregateError(failures, 'Vendor network cleanup did not settle.'); identities.delete(network); + removedNetworks.add(network); } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 6bcf5a5..47199b0 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -7,7 +7,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; import { AGENT_IMAGE, assertBuiltAgentImage, buildAgentImage } from '../agents/container/image.ts'; import { createContainerProfile, disposeContainerProfile } from '../agents/container/profile.ts'; -import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, +import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, startValidatedContainer, hasExactOptions, validateContainer } from '../agents/container/run.ts'; import { createTaskClone } from '../git/clone.ts'; import { createVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../agents/network/network.ts'; @@ -103,6 +103,13 @@ describe('real Docker agent isolation', () => { expect(runContainer(profile(data, phase, 'phase-worktree'))).toBe(''); }, 60_000); + it('removes the invocation proxy and network after the container settles', () => { + const data = fixture(), valid = profile(data, 'planning', 'noop'); + expect(runContainer(valid)).toBe(''); + expect(spawnSync('docker', ['container', 'inspect', valid.network.proxyContainer]).status).not.toBe(0); + expect(spawnSync('docker', ['network', 'inspect', valid.network.name]).status).not.toBe(0); + }, 60_000); + it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { const data = fixture(); process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; @@ -258,10 +265,10 @@ describe('real Docker agent isolation', () => { it('does not remove an active container when a duplicate attempt name collides', () => { const data = fixture(), captured = invocation(data.clone, 'planning'); - const common = governed(captured); - const first = createContainerProfile({ ...common, filesystems: data.filesystems, + const duplicateInvocation = captureInvocation({ ...captured }); + const first = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId }); - const duplicate = createContainerProfile({ ...common, filesystems: data.filesystems, + const duplicate = createContainerProfile({ ...governed(duplicateInvocation), filesystems: data.filesystems, inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId }); profiles.push(first, duplicate); docker(...first.args); containers.add(first.name); @@ -420,6 +427,14 @@ describe('real Docker agent isolation', () => { expect(() => validateContainer(valid.name, valid)).toThrow('DNS configuration'); docker('rm', '--force', valid.name); containers.delete(valid.name); + for (const extra of [['--add-host=api.openai.com:127.0.0.1'], ['--publish=127.0.0.1::3128']]) { + const changedArgs = [...valid.args.slice(0, valid.args.indexOf(imageId)), ...extra, + ...valid.args.slice(valid.args.indexOf(imageId))]; + docker(...changedArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('host or port configuration'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + } + const imageIndex = valid.args.indexOf(imageId); const namespaceArgs = [...valid.args.slice(0, imageIndex), '--uts=host', ...valid.args.slice(imageIndex)]; docker(...namespaceArgs); containers.add(valid.name); @@ -438,10 +453,23 @@ describe('real Docker agent isolation', () => { try { docker('run', '--detach', '--name', rogue, `--network=${valid.network.name}`, '--entrypoint', 'node', imageId, '-e', 'setInterval(()=>{},1000)'); - expect(() => createValidatedContainer(valid)).toThrow('network or proxy changed'); + expect(() => createValidatedContainer(valid)).toThrow('cleanup did not settle'); const absent = spawnSync('docker', ['container', 'inspect', valid.name], { encoding: 'utf8' }); expect(absent.status).not.toBe(0); - } finally { spawnSync('docker', ['rm', '--force', rogue], { stdio: 'ignore' }); } + } finally { + spawnSync('docker', ['rm', '--force', rogue], { stdio: 'ignore' }); + disposeContainerProfile(valid); + } + }, 60_000); + + it('revalidates the agent attachment immediately before start', () => { + const data = fixture(), valid = profile(data, 'planning', 'must-not-run'); + createValidatedContainer(valid); containers.add(valid.name); + docker('network', 'disconnect', valid.network.name, valid.name); + docker('network', 'connect', 'bridge', valid.name); + expect(() => startValidatedContainer(valid)).toThrow(/network attachment|lockdown/); + containers.delete(valid.name); + expect(spawnSync('docker', ['container', 'inspect', valid.name]).status).not.toBe(0); }, 60_000); it('creates containers from the captured immutable image rather than its mutable tag', () => { diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index f19b17d..9e373f5 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -88,6 +88,9 @@ describe('vendor-only egress', () => { it.each([ ['host namespace', ['--pid=host']], ['extra Node environment', ['--env', 'NODE_OPTIONS=--trace-warnings']], + ['DNS override', ['--dns=8.8.8.8']], + ['host override', ['--add-host=api.anthropic.com:127.0.0.1']], + ['published proxy port', ['--publish=127.0.0.1::3128']], ] as const)('rejects a proxy replaced with %s before launch', (_label, extra) => { const replacementInvocation = captureInvocation({ ...invocation, attemptId: 'mutated-proxy-probe', deadline: Date.now() + 60_000 }); From 9aa1b897379367c832b42b142911a0c65ed370c1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 12:21:53 -0700 Subject: [PATCH 06/20] Stabilize live Claude marker probe --- test/agent-container.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 47199b0..dba7ee9 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -516,7 +516,7 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); const envelope = JSON.parse(output) as { result?: string; is_error?: boolean }; expect(envelope.is_error).not.toBe(true); - expect(envelope.result?.trim()).toBe('codeboost-schema-marker'); + expect(envelope.result).toContain('codeboost-schema-marker'); }, 6 * 60_000); } }); From 263cc57cf857589fe93dc04a255f0af65750fce6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 23:05:00 -0700 Subject: [PATCH 07/20] Port D2 regression tests to named isolation probes D3 replaced raw test argv with fixed isolation probes, so the seeding, page-rounding and namespace regressions from D2 now use the metadata and noop probes. Co-Authored-By: Claude Opus 5.5 --- test/agent-container.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index dba7ee9..c5abbec 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -123,19 +123,18 @@ 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); From cd58af2e16c8c4be6e833c99f1ffa7eaecaee1a7 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 23:44:24 -0700 Subject: [PATCH 08/20] Carry D2 seccomp pinning into the egress proxy Request and require Docker's builtin seccomp profile for the vendor egress proxy too, and port D2's new regressions to the named isolation probes. Co-Authored-By: Claude Opus 5.5 --- agents/network/network.ts | 7 ++++--- test/agent-container.test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/agents/network/network.ts b/agents/network/network.ts index 0cda964..ecc5f6e 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -84,8 +84,9 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp if (!inspect?.State?.Running || inspect.Config?.Image !== identity.imageId || inspect.Config?.User !== '10001:10001' || inspect.Config?.Labels?.['io.codeboost.egress'] !== identity.allocationId || !inspect.HostConfig?.ReadonlyRootfs || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') - || (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1 - || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') + || (inspect.HostConfig.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.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 !== '' @@ -130,7 +131,7 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string '--label', `io.codeboost.egress=${allocationId}`, name], remaining()); proxyPlanned = true; docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', - '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', '--cpus=.25', '--network', name, '--network-alias', 'codeboost-proxy', '--label', `io.codeboost.egress=${allocationId}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`, '--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs'], remaining()); diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index c5abbec..6a56bf7 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -279,7 +279,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. @@ -304,7 +304,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); @@ -375,7 +375,7 @@ describe('real Docker agent isolation', () => { 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', () => { @@ -387,7 +387,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); From 52eb54f8a628d41a7f4d6ed4b6da7fe43f3d5786 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 23:51:18 -0700 Subject: [PATCH 09/20] Own vendor networks on profile failure and bound network cleanup - Claim the vendor network before any fallible profile work and remove it if creation fails, aggregating cleanup errors. - Reserve cleanup time inside createVendorNetwork's deadline so failure cleanup cannot overrun the caller's timeoutMs. - Compare the live Claude marker exactly, tolerating only one wrapping pair of backticks or quotes. Co-Authored-By: Claude Opus 5.5 --- agents/container/profile.ts | 35 +++++++++++++++++++---------------- agents/network/network.ts | 8 +++++--- test/agent-container.test.ts | 12 +++++++++++- test/agent-network.test.ts | 16 ++++++++++++++++ 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 90bcd5c..6cae495 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -169,22 +169,24 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil assertTaskFilesystems(filesystems, invocation.clone); assertVendorNetwork(options.network, invocation); if (claimedNetworks.has(options.network)) throw new Error('Vendor network already belongs to another container profile.'); - assertPhasePolicy(options.policy, invocation); - const command = assertAgentCommand(options.command, options.policy, invocation.vendor); - const sourceInput = captureInput(options.inputDirectory); - 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; + // 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, @@ -235,11 +237,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil auth: authIdentity, cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone, deadline: invocation.deadline, network: options.network, policy: options.policy, invocation })); - claimedNetworks.add(options.network); 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/network/network.ts b/agents/network/network.ts index ecc5f6e..33ee50a 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -119,7 +119,9 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string timeoutMs = 60_000): VendorNetwork { assertBuiltAgentImage(imageId); const vendor = invocation.vendor; - const remaining = deadline(timeoutMs), allocationId = randomUUID(); + // Setup runs inside the caller's budget minus a cleanup reserve, so failure cleanup cannot overrun timeoutMs. + 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('-', ''); @@ -155,9 +157,9 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string } catch (error) { const failures: unknown[] = []; if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer], - deadline(30_000), 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + overall, 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } if (networkPlanned) try { remove(['network', 'rm', name], ['network', 'inspect', name], - deadline(30_000), 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + overall, 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } if (failures.length) throw new AggregateError([error, ...failures], 'Vendor network creation and cleanup failed.'); throw error; } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 6a56bf7..8ced208 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -372,6 +372,14 @@ describe('real Docker agent isolation', () => { inputDirectory: data.input, command: ['true'], 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('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); @@ -515,7 +523,9 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); const envelope = JSON.parse(output) as { result?: string; is_error?: boolean }; expect(envelope.is_error).not.toBe(true); - expect(envelope.result).toContain('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 index 9e373f5..752ac46 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -1,5 +1,8 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; +import { mkdtempSync, 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, @@ -27,6 +30,19 @@ beforeAll(() => { afterAll(() => 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('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); From 52af54b80962af7a34adf68a004f9f435b481d84 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 00:31:46 -0700 Subject: [PATCH 10/20] Carry D2 deadline, runtime and capture rules into D3 - Clamp runContainer to the captured invocation deadline, as the other launch paths do, and pin and require the runc runtime for the egress proxy. - Port D2's new regressions to the named isolation probes, and give each captured test request its own attempt identity now that an attempt can only be captured once. Co-Authored-By: Claude Opus 5.5 --- agents/container/run.ts | 2 +- agents/network/network.ts | 5 +++-- test/agent-container.test.ts | 32 +++++++++++++------------------- test/agent-network.test.ts | 2 +- test/agent-policy.test.ts | 3 ++- 5 files changed, 20 insertions(+), 24 deletions(-) diff --git a/agents/container/run.ts b/agents/container/run.ts index 610abba..943dc0e 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -310,7 +310,7 @@ export function startValidatedContainer(profile: ContainerProfile, timeoutMs = 6 export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, secrets: Readonly> = {}): string { - const remaining = createDeadline(timeoutMs); + const remaining = createDeadline(profileTimeout(profile, timeoutMs)); createValidatedContainer(profile, remaining(), secrets); let startBudget: number; try { startBudget = remaining(); } diff --git a/agents/network/network.ts b/agents/network/network.ts index 33ee50a..9d59d0b 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -68,7 +68,7 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp 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 }; + PortBindings?: Record | null; PublishAllPorts?: boolean; Runtime?: string }; NetworkSettings?: { Networks?: Record; Ports?: Record }; Mounts?: unknown[] } | undefined; const image = JSON.parse(docker(['image', 'inspect', identity.imageId], remaining()))[0] as @@ -87,6 +87,7 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp || (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' || 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 !== '' @@ -133,7 +134,7 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string '--label', `io.codeboost.egress=${allocationId}`, name], remaining()); proxyPlanned = true; docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', - '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', + '--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'], remaining()); diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 8ced208..64acf15 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -64,10 +64,10 @@ const governed = (captured: InvocationInput, probe: IsolationProbe = 'noop') => function profile(data: ReturnType, phase: Phase, command: IsolationProbe | ((policy: ReturnType) => AgentCommand), options: { - vendor?: 'codex' | 'claude'; authProbe?: boolean; codexAuthFile?: string; claudeToken?: string; + vendor?: 'codex' | 'claude'; authProbe?: boolean; codexAuthFile?: string; claudeToken?: string; deadlineMs?: number; } = {}) { const vendor = options.vendor ?? 'codex'; - const captured = invocation(data.clone, phase, vendor); + 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); @@ -264,10 +264,9 @@ 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 duplicateInvocation = captureInvocation({ ...captured }); const first = createContainerProfile({ ...governed(captured), filesystems: data.filesystems, inputDirectory: data.input, codexAuthFile: data.fakeAuth, imageId }); - const duplicate = createContainerProfile({ ...governed(duplicateInvocation), filesystems: data.filesystems, + 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); @@ -345,7 +344,7 @@ describe('real Docker agent isolation', () => { }, 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); @@ -354,22 +353,17 @@ 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); - const started = performance.now(); - expect(() => runContainer(late, 60_000)).toThrow(); - expect(performance.now() - started).toBeLessThan(15_000); + it('refuses to launch once the captured invocation deadline has passed', () => { + const data = fixture(), late = profile(data, 'planning', 'noop', { deadlineMs: 1_500 }); + execFileSync('sleep', ['2']); + expect(() => runContainer(late, 60_000)).toThrow('deadline has passed'); }, 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', () => { @@ -387,7 +381,7 @@ describe('real Docker agent isolation', () => { }, 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'); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index 752ac46..f4e57ac 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -108,7 +108,7 @@ describe('vendor-only egress', () => { ['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', + 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 diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts index e4e2939..5cd9c22 100644 --- a/test/agent-policy.test.ts +++ b/test/agent-policy.test.ts @@ -3,10 +3,11 @@ import { captureInvocation, type InvocationInput, type Phase } from '../agents/c 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}`, + deadline: 2000, attemptId: `attempt-${phase}-${++attempt}`, context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, }, 1000); From 2df056a8279934e88f22f4b719fcb8318c8099d1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 10:05:25 -0700 Subject: [PATCH 11/20] Require captured invocations for policies and serialize Docker suites - createPhasePolicy and createVendorNetwork now reject any invocation not produced by captureInvocation, so a forged request cannot define tools, an allowlist or a network. - Run the agent isolation CI suites with --no-file-parallelism, since the Docker suites share one image tag and daemon. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/agent-isolation.yml | 3 ++- agents/network/network.ts | 3 ++- agents/policy.ts | 4 +++- test/agent-policy.test.ts | 4 ++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index f851a9b..dd08647 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 test/agent-network.test.ts test/agent-policy.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 diff --git a/agents/network/network.ts b/agents/network/network.ts index 9d59d0b..265cc84 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import type { InvocationInput } from '../contract.ts'; +import { assertCapturedInvocation, type InvocationInput } from '../contract.ts'; import { assertBuiltAgentImage } from '../container/image.ts'; export const VENDOR_HOSTS = Object.freeze({ @@ -118,6 +118,7 @@ export function assertVendorNetwork(network: VendorNetwork, invocation?: Invocat 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. diff --git a/agents/policy.ts b/agents/policy.ts index 826c856..eb9efc0 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -1,5 +1,5 @@ import type { InvocationInput, Phase } from './contract.ts'; -import { permitsCommand } from './contract.ts'; +import { assertCapturedInvocation, permitsCommand } from './contract.ts'; export type AgentTool = 'read' | 'list' | 'search' | 'write' | 'edit' | 'runner-command'; export interface PhasePolicy { @@ -30,6 +30,8 @@ export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy, } 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'); diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts index 5cd9c22..ba64650 100644 --- a/test/agent-policy.test.ts +++ b/test/agent-policy.test.ts @@ -12,6 +12,10 @@ const request = (phase: Phase, vendor: 'claude' | 'codex' = 'claude'): Invocatio }, 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 }); From 5799d7084eae770a6f8cd01042eb1626d9df621d Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 10:30:10 -0700 Subject: [PATCH 12/20] Bound network checks by the invocation deadline and reconcile late creates - createContainerProfile bounds its vendor network revalidation by the invocation's remaining time, and createVendorNetwork clamps its budget to the invocation deadline; both refuse once it has passed. - Track whether the network create or proxy run client was killed, and give that object a settle window inside the caller's cleanup reserve, removing it if it lands late. Co-Authored-By: Claude Opus 5.5 --- agents/container/profile.ts | 4 ++- agents/network/network.ts | 55 +++++++++++++++++++++++++++--------- test/agent-container.test.ts | 14 +++++++-- test/agent-network.test.ts | 22 +++++++++++++++ 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 6cae495..8545629 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -167,7 +167,9 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container profile requires the immutable built image ID.'); assertBuiltAgentImage(options.imageId); assertTaskFilesystems(filesystems, invocation.clone); - assertVendorNetwork(options.network, invocation); + 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); diff --git a/agents/network/network.ts b/agents/network/network.ts index 265cc84..fb55b2f 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -33,15 +33,22 @@ const docker = (args: readonly string[], timeout: number) => execFileSync('docke }).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) => { - const before = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', - env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); - if (before.status !== 0) { - if (absent(before)) return; - throw new Error(`Failed to establish ownership of ${kind}.`); + 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(before.stdout || '[]')[0] as + 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}.`); @@ -122,6 +129,10 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string 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()}`; @@ -129,16 +140,26 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string const subnetSeed = randomUUID().replaceAll('-', ''); const subnet = `10.254.${parseInt(subnetSeed.slice(0, 2), 16)}.${parseInt(subnetSeed.slice(2, 4), 16) & 0xf8}/29`; let networkPlanned = false, proxyPlanned = false; + const unsettled = new Set(); + // 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; - docker(['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, - '--label', `io.codeboost.egress=${allocationId}`, name], remaining()); + create(name, ['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, + '--label', `io.codeboost.egress=${allocationId}`, name]); proxyPlanned = true; - docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', + 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'], remaining()); + '--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs']); docker(['network', 'connect', 'bridge', proxyContainer], remaining()); docker(['exec', proxyContainer, 'node', '-e', [ "const net=require('node:net');let attempts=0;", @@ -158,10 +179,18 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string 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; if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer], - overall, 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + cleanupBudget, 'vendor proxy', allocationId, settleBy(proxyContainer)); } + catch (cleanupError) { failures.push(cleanupError); } if (networkPlanned) try { remove(['network', 'rm', name], ['network', 'inspect', name], - overall, 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } + cleanupBudget, 'vendor network', allocationId, settleBy(name)); } + catch (cleanupError) { failures.push(cleanupError); } if (failures.length) throw new AggregateError([error, ...failures], 'Vendor network creation and cleanup failed.'); throw error; } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 64acf15..bf9ff92 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -354,11 +354,21 @@ describe('real Docker agent isolation', () => { }, 60_000); it('refuses to launch once the captured invocation deadline has passed', () => { - const data = fixture(), late = profile(data, 'planning', 'noop', { deadlineMs: 1_500 }); - execFileSync('sleep', ['2']); + 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(() => 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(), trusted = governed(invocation(data.clone, 'review')); const forged = { ...trusted.invocation, phase: 'execute' as Phase }; diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index f4e57ac..f07969c 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -43,6 +43,28 @@ describe('vendor-only egress', () => { 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 networks = () => new Set(docker('network', 'ls', '--quiet', '--filter', 'label=io.codeboost.egress') + .split('\n').filter(Boolean)); + const before = networks(); + const shim = mkdtempSync(join(tmpdir(), 'docker-shim-')); + 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. + writeFileSync(join(shim, 'docker'), ['#!/bin/sh', + `if [ "$1" = network ] && [ "$2" = create ]; then ( sleep 7; exec '${realDocker}' "$@" ) >/dev/null 2>&1 createVendorNetwork(lateInvocation, imageId, 9_000)).toThrow(); } + finally { process.env.PATH = path; rmSync(shim, { recursive: true, force: true }); } + execFileSync('sleep', ['3']); + const orphans = [...networks()].filter(id => !before.has(id)); + for (const id of orphans) docker('network', 'rm', id); + expect(orphans).toEqual([]); + }, 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); From b57659e5b15a15423939a97887b486fb4ade5802 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 10:47:48 -0700 Subject: [PATCH 13/20] End CLI option parsing before agent prompts - Place the prompt after `--` for both Codex and Claude, so a prompt beginning with `-` cannot be parsed as a CLI option such as the sandbox bypass flag. For Claude this also stops a trailing prompt being taken as another --add-dir value. - Scope the late-network regression to the network it created, since the main CI workflow runs test files in parallel. Co-Authored-By: Claude Opus 5.5 --- agents/policy.ts | 9 ++++++--- test/agent-network.test.ts | 25 ++++++++++++++----------- test/agent-policy.test.ts | 11 +++++++++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/agents/policy.ts b/agents/policy.ts index eb9efc0..17c6349 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -67,10 +67,11 @@ export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentC if (assertPhasePolicy(policy).vendor !== 'claude') throw new Error('Claude command requires a Claude invocation policy.'); const writable = policy.worktree === 'read-write'; const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep'; - return command(policy, ['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config', + // `--` 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']); + '--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir', '/run/codeboost-input', '--', prompt]); } export function codexBaseArguments(policy: PhasePolicy): readonly string[] { @@ -82,7 +83,9 @@ export function codexBaseArguments(policy: PhasePolicy): readonly string[] { export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCommand { if (!prompt || prompt.includes('\0')) throw new Error('Codex prompt must be nonempty and contain no NUL.'); const sandbox = policy.worktree === 'read-write' ? 'workspace-write' : 'read-only'; - return command(policy, [...codexBaseArguments(policy), 'exec', '--sandbox', sandbox, '--skip-git-repo-check', prompt]); + // `--` 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' diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index f07969c..ecca5ee 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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'; @@ -44,25 +44,28 @@ describe('vendor-only egress', () => { }, 60_000); it('removes a network that lands in the daemon after its create client was killed', () => { - const networks = () => new Set(docker('network', 'ls', '--quiet', '--filter', 'label=io.codeboost.egress') - .split('\n').filter(Boolean)); - const before = networks(); - const shim = mkdtempSync(join(tmpdir(), 'docker-shim-')); + 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 ( sleep 7; exec '${realDocker}' "$@" ) >/dev/null 2>&1 '${requested}'; ` + + `( sleep 7; exec '${realDocker}' "$@" ) >/dev/null 2>&1 createVendorNetwork(lateInvocation, imageId, 9_000)).toThrow(); } - finally { process.env.PATH = path; rmSync(shim, { recursive: true, force: true }); } + let name = ''; + try { + expect(() => 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 orphans = [...networks()].filter(id => !before.has(id)); - for (const id of orphans) docker('network', 'rm', id); - expect(orphans).toEqual([]); + 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('pins the host list with each vendor profile', () => { diff --git a/test/agent-policy.test.ts b/test/agent-policy.test.ts index ba64650..729f2f1 100644 --- a/test/agent-policy.test.ts +++ b/test/agent-policy.test.ts @@ -41,6 +41,17 @@ describe('agent phase policy', () => { 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; From c31e7901e0cda62b2f5ceb03a1b90dca6b36d9d8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 11:06:18 -0700 Subject: [PATCH 14/20] Keep early CONNECT payload and keep Docker suites out of parallel CI - The egress proxy stopped reading after parsing CONNECT but left its data listener attached, so bytes a client sent before the tunnel was established (such as a TLS ClientHello) were dropped unless they arrived in the header chunk. It now detaches and pauses until the tunnel is piped. An upstream error inside an established tunnel now resets the client instead of writing an HTTP status into the TLS stream, and a closed client tears down its upstream. - Allow tests to override the proxy's listen and upstream ports; the production defaults stay 3128 and 443. - Exclude the Docker agent suites from the main CI npm test, since the Agent isolation workflow already runs them one file at a time. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/agent-isolation.yml | 2 +- .github/workflows/ci.yml | 4 +- agents/network/proxy.mjs | 26 +++++---- test/agent-proxy.test.ts | 78 +++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 test/agent-proxy.test.ts diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index dd08647..395e87e 100644 --- a/.github/workflows/agent-isolation.yml +++ b/.github/workflows/agent-isolation.yml @@ -29,4 +29,4 @@ jobs: - run: npm ci --ignore-scripts - run: npm run typecheck # 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 + - 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/network/proxy.mjs b/agents/network/proxy.mjs index 00cba4f..fe6275f 100644 --- a/agents/network/proxy.mjs +++ b/agents/network/proxy.mjs @@ -2,6 +2,10 @@ 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 refuse = (socket, status = '403 Forbidden') => { socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); @@ -9,36 +13,40 @@ const refuse = (socket, status = '403 Forbidden') => { createServer(client => { client.setTimeout(300_000, () => client.destroy()); - let request = Buffer.alloc(0), settled = false; + let request = Buffer.alloc(0); const receive = chunk => { - if (settled) return; request = Buffer.concat([request, chunk], request.length + chunk.length); if (request.length > 8192) { - settled = true; + client.off('data', receive); refuse(client, '431 Request Header Fields Too Large'); return; } const boundary = request.indexOf('\r\n\r\n'); if (boundary < 0) return; - settled = true; + // 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 match = /^CONNECT ([a-z0-9.-]+):443 HTTP\/1\.[01]$/.exec(line); - const host = match?.[1]; + const host = connectLine.exec(line)?.[1]; if (!host || !allowed.has(host)) { refuse(client); return; } - const upstream = connect({ host, port: 443 }); + 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); }); - upstream.once('error', () => refuse(client, '502 Bad Gateway')); + // 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(3128, '0.0.0.0'); +}).listen(listenPort, '0.0.0.0'); diff --git a/test/agent-proxy.test.ts b/test/agent-proxy.test.ts new file mode 100644 index 0000000..b3f4d14 --- /dev/null +++ b/test/agent-proxy.test.ts @@ -0,0 +1,78 @@ +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('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/); + }); +}); From 9876b04e5e9f4c85496496d5ccfa3f5be75512e2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 11:12:29 -0700 Subject: [PATCH 15/20] Capture the network suite invocation after the image build createVendorNetwork now refuses an expired invocation, so capturing the suite's invocation at module load with a 60 s deadline could fail the whole file after a cold image build. Capture it in beforeAll after the build, with a deadline covering the suite. Co-Authored-By: Claude Opus 5.5 --- test/agent-network.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index ecca5ee..0a90f58 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -7,14 +7,9 @@ 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 } from '../agents/contract.ts'; +import { captureInvocation, type InvocationInput } from '../agents/contract.ts'; -let imageId = '', network: VendorNetwork; -const invocation = captureInvocation({ - clone: { id: 'clone-network', taskId: 'task-network', directory: '/tmp/network', head: 'a'.repeat(40) }, - vendor: 'claude', phase: 'planning', approvedArgv: [], deadline: Date.now() + 60_000, attemptId: 'network-probe', - context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, -}); +let imageId = '', network: VendorNetwork, invocation: InvocationInput; const docker = (...args: string[]) => execFileSync('docker', args, { encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], }).trim(); @@ -25,6 +20,12 @@ const curl = (url: string, direct = false) => spawnSync('docker', ['run', '--rm' 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); afterAll(() => removeVendorNetwork(network), 60_000); From e90b57967da939ebb97086fed516f67a837eea79 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 11:27:26 -0700 Subject: [PATCH 16/20] Bind vendor networks to daemon IDs and validate the proxy restart policy - Record the network and proxy container IDs returned at creation. Validation inspects those IDs and requires the network name and proxy endpoint to match them, so a removed-and-recreated network or proxy with identical attributes is refused. removeVendorNetwork deletes by ID and leaves a same-named stand-in in place, which surfaces as a cleanup failure. - Require the proxy's default no-restart policy, as for the agent and keeper. Co-Authored-By: Claude Opus 5.5 --- agents/network/network.ts | 39 ++++++++++++++++++++++++++------------ test/agent-network.test.ts | 22 +++++++++++++++++---- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/agents/network/network.ts b/agents/network/network.ts index fb55b2f..3fcf542 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -15,7 +15,9 @@ export interface VendorNetwork { readonly vendor: InvocationInput['vendor']; } interface NetworkIdentity { readonly allocationId: string; readonly imageId: string; readonly invocation: InvocationInput; - readonly subnet: string; readonly proxyIp: string } + 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 }); @@ -67,34 +69,44 @@ const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInp if (invocation && (identity.invocation !== invocation || network.vendor !== invocation.vendor)) throw new Error('Vendor network does not belong to this invocation.'); assertBuiltAgentImage(identity.imageId); - const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], remaining()))[0] as - { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record; Env?: string[]; + // 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 }; + 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(docker(['network', 'inspect', network.name], remaining()))[0] as - { Internal?: boolean; Driver?: string; Labels?: Record; IPAM?: { Config?: Array<{ Subnet?: string }> }; + 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?.State?.Running || inspect.Config?.Image !== identity.imageId || inspect.Config?.User !== '10001:10001' + 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 !== '' @@ -152,10 +164,10 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string }; try { networkPlanned = true; - create(name, ['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, + const networkId = create(name, ['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, '--label', `io.codeboost.egress=${allocationId}`, name]); proxyPlanned = true; - create(proxyContainer, ['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', + const proxyId = 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(',')}`, @@ -173,7 +185,9 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string 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 })); + if (!/^[0-9a-f]{64}$/.test(networkId) || !/^[0-9a-f]{64}$/.test(proxyId)) + throw new Error('Docker did not return the created network and proxy IDs.'); + identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet, proxyIp, networkId, proxyId })); validateVendorNetwork(network, invocation, undefined, remaining); remaining(); return network; @@ -205,9 +219,10 @@ export function removeVendorNetwork(network: VendorNetwork): void { assertBuiltAgentImage(identity.imageId); const allocationId = identity.allocationId; const remaining = deadline(30_000), failures: unknown[] = []; - try { remove(['rm', '--force', network.proxyContainer], ['container', 'inspect', network.proxyContainer], + // 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', network.name], ['network', 'inspect', network.name], + 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); diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index 0a90f58..b4ed3ae 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -127,7 +127,14 @@ describe('vendor-only egress', () => { } }, 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']], @@ -138,17 +145,24 @@ describe('vendor-only egress', () => { deadline: Date.now() + 60_000 }); const replacement = createVendorNetwork(replacementInvocation, imageId); const inspected = JSON.parse(docker('container', 'inspect', replacement.proxyContainer))[0] as - { Config: { Labels: Record } }; + { 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', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', - '--cpus=.25', ...extra, '--network', replacement.name, '--network-alias', 'codeboost-proxy', + '--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 { removeVendorNetwork(replacement); } + } 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); }); From e8e8d57e9924ae82a34de413cdd706bce3a53bb8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 11:50:10 -0700 Subject: [PATCH 17/20] Apply the proxy header limit to headers only and bound profile checks - The egress proxy applied its 8 KiB limit to everything received so far, so a CONNECT header followed in the same read by a large ClientHello was refused with 431. The limit now covers only the header. - assertContainerProfile clamps its network revalidation budget to the invocation deadline, including for callers using the default budget. - Give the late-keeper regression enough budget to pass on a loaded daemon. Co-Authored-By: Claude Opus 5.5 --- agents/container/profile.ts | 3 ++- agents/network/proxy.mjs | 5 +++-- test/agent-container.test.ts | 15 +++++++++++---- test/agent-proxy.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 8545629..d192d23 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -117,7 +117,8 @@ export function assertContainerProfile(profile: ContainerProfile, timeoutMs = 30 const expected = identities.get(profile); if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); assertTaskFilesystems(expected.filesystems, expected.clone); - assertVendorNetwork(expected.network, expected.invocation, profile.name, timeoutMs); + // 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)) diff --git a/agents/network/proxy.mjs b/agents/network/proxy.mjs index fe6275f..9bb4378 100644 --- a/agents/network/proxy.mjs +++ b/agents/network/proxy.mjs @@ -16,12 +16,13 @@ createServer(client => { let request = Buffer.alloc(0); const receive = chunk => { request = Buffer.concat([request, chunk], request.length + chunk.length); - if (request.length > 8192) { + // The limit applies to the header only; tunnel bytes sent in the same read may follow it. + const boundary = request.indexOf('\r\n\r\n'); + if (boundary < 0 ? request.length > 8192 : boundary + 4 > 8192) { client.off('data', receive); refuse(client, '431 Request Header Fields Too Large'); return; } - const boundary = request.indexOf('\r\n\r\n'); 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); diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index bf9ff92..5330c62 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; 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 { 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'; @@ -328,16 +328,17 @@ 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([]); @@ -353,6 +354,12 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); + 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)]); diff --git a/test/agent-proxy.test.ts b/test/agent-proxy.test.ts index b3f4d14..0511fcf 100644 --- a/test/agent-proxy.test.ts +++ b/test/agent-proxy.test.ts @@ -65,6 +65,30 @@ describe('vendor egress proxy', () => { 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 hosts outside the vendor allowlist', async () => { const { proxyPort, upstreamPort } = await startProxy(); const client = connect(proxyPort, '127.0.0.1'); From 647a0bbaadc6c6f458479593b5de04171e91baa6 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 12:02:01 -0700 Subject: [PATCH 18/20] Clean up failed network setup by ID and bound proxy header buffering - createVendorNetwork keeps the network and proxy IDs as soon as each create returns and uses them for the rest of setup and for failure cleanup. Names are used only for a create whose ID never came back, so a same-named stand-in carrying the allocation label is not deleted. - The proxy searches for the header end only within the 8 KiB limit and never keeps more than an unfinished header between reads, so a streamed unterminated header cannot grow its buffer. Co-Authored-By: Claude Opus 5.5 --- agents/network/network.ts | 31 ++++++++++++++++++------------- agents/network/proxy.mjs | 9 ++++++--- test/agent-network.test.ts | 25 +++++++++++++++++++++++++ test/agent-proxy.test.ts | 20 ++++++++++++++++++++ 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/agents/network/network.ts b/agents/network/network.ts index 3fcf542..56c451a 100644 --- a/agents/network/network.ts +++ b/agents/network/network.ts @@ -152,7 +152,13 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string const subnetSeed = randomUUID().replaceAll('-', ''); const subnet = `10.254.${parseInt(subnetSeed.slice(0, 2), 16)}.${parseInt(subnetSeed.slice(2, 4), 16) & 0xf8}/29`; let networkPlanned = false, proxyPlanned = false; + // 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(); @@ -164,29 +170,27 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string }; try { networkPlanned = true; - const networkId = create(name, ['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, - '--label', `io.codeboost.egress=${allocationId}`, name]); + networkId = createdId(create(name, ['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet, + '--label', `io.codeboost.egress=${allocationId}`, name]), 'network'); proxyPlanned = true; - const proxyId = create(proxyContainer, ['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', + 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']); - docker(['network', 'connect', 'bridge', proxyContainer], remaining()); - docker(['exec', proxyContainer, 'node', '-e', [ + '--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', proxyContainer], remaining()))[0] as + 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 }); - if (!/^[0-9a-f]{64}$/.test(networkId) || !/^[0-9a-f]{64}$/.test(proxyId)) - throw new Error('Docker did not return the created network and proxy IDs.'); identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet, proxyIp, networkId, proxyId })); validateVendorNetwork(network, invocation, undefined, remaining); remaining(); @@ -199,11 +203,12 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string const settleBy = (object: string) => unsettled.has(object) ? performance.now() + Math.min(CREATE_SETTLE_MS, reserveLeft) : 0; const cleanupBudget = overall; - if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer], - cleanupBudget, 'vendor proxy', allocationId, settleBy(proxyContainer)); } + 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', name], ['network', 'inspect', name], - cleanupBudget, 'vendor network', allocationId, settleBy(name)); } + 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; diff --git a/agents/network/proxy.mjs b/agents/network/proxy.mjs index 9bb4378..a3a8975 100644 --- a/agents/network/proxy.mjs +++ b/agents/network/proxy.mjs @@ -7,6 +7,8 @@ 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`); }; @@ -15,10 +17,11 @@ 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); - // The limit applies to the header only; tunnel bytes sent in the same read may follow it. - const boundary = request.indexOf('\r\n\r\n'); - if (boundary < 0 ? request.length > 8192 : boundary + 4 > 8192) { + 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; diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index b4ed3ae..088efdc 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -69,6 +69,31 @@ describe('vendor-only 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); diff --git a/test/agent-proxy.test.ts b/test/agent-proxy.test.ts index 0511fcf..1d33514 100644 --- a/test/agent-proxy.test.ts +++ b/test/agent-proxy.test.ts @@ -89,6 +89,26 @@ describe('vendor egress proxy', () => { 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'); From 75fdee90c3ee7e431448746b01c45672fcb29341 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 12:07:55 -0700 Subject: [PATCH 19/20] Run live auth probes through runContainer and guard network teardown - The opt-in authenticated Codex and Claude probes now launch through runContainer, so they exercise creation, validation and removal instead of raw docker create and start. The Claude token is passed only as the profile's secret. - The network suite skips teardown when setup never produced a network, so a setup failure is reported instead of a teardown TypeError. Co-Authored-By: Claude Opus 5.5 --- test/agent-container.test.ts | 16 ++++++---------- test/agent-network.test.ts | 3 ++- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 5330c62..7e4c3c9 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -514,10 +514,9 @@ describe('real Docker agent isolation', () => { if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); const authProfile = profile(data, 'planning', policy => createCodexCommand(policy, 'Reply only with this exact marker: codeboost-schema-marker'), - { authProbe: true, codexAuthFile: authFile }); - docker(...authProfile.args); containers.add(authProfile.name); - const output = docker('start', '--attach', authProfile.name); - docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); + { 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); @@ -526,12 +525,9 @@ describe('real Docker agent isolation', () => { if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); const authProfile = profile(data, 'planning', policy => createClaudeCommand(policy, 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, without quotes or Markdown formatting.'), - { vendor: 'claude', authProbe: true, claudeToken: token }); - const result = execFileSync('docker', authProfile.args, { encoding: 'utf8', timeout: 60_000, - env: { PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, CLAUDE_CODE_OAUTH_TOKEN: token } }); - void result; containers.add(authProfile.name); - const output = docker('start', '--attach', authProfile.name); - docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); + { 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); // Tolerate one wrapping pair of backticks or quotes, but nothing else around the value. diff --git a/test/agent-network.test.ts b/test/agent-network.test.ts index 088efdc..93e8a63 100644 --- a/test/agent-network.test.ts +++ b/test/agent-network.test.ts @@ -28,7 +28,8 @@ beforeAll(() => { }); network = createVendorNetwork(invocation, imageId); }, 10 * 60_000); -afterAll(() => removeVendorNetwork(network), 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', () => { From a2852e8c46ccabf7076c164eed3acee2bb9be4da Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 12:20:47 -0700 Subject: [PATCH 20/20] Authenticate profiles before destructive container cleanup removeContainerOrThrow now refuses a profile that the trusted builder did not register before running any Docker command, so a copied profile's public name and ownership label cannot remove another invocation's container. The launch paths already rejected copies through their first profileTimeout call; this makes the cleanup boundary enforce it itself. Co-Authored-By: Claude Opus 5.5 --- agents/container/profile.ts | 4 ++++ agents/container/run.ts | 6 +++++- test/agent-container.test.ts | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index d192d23..02c5f26 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -129,6 +129,10 @@ export function assertContainerProfile(profile: ContainerProfile, timeoutMs = 30 } } +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); diff --git a/agents/container/run.ts b/agents/container/run.ts index 943dc0e..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; diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 7e4c3c9..dd2295f 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -391,6 +391,16 @@ describe('real Docker agent isolation', () => { 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);