From 36cb59da3a634075f723ed348bcf9690a6aeaef8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 14:50:58 -0700 Subject: [PATCH 01/11] D5: Add the combined real-Docker isolation gate - Probe scripts asserted forbidden actions with `! command` under `set -e`, which never fails, so the metadata, read-only isolation and capacity probes reported success even when isolation broke. Replace every negated check with a `deny` helper that exits and names the breach. - Add agent-gate breach self-tests: each negative production probe runs in a container missing one protection and must report that breach. - Add probes and real-profile tests for scratch byte and inode ceilings (both vendors), metadata link/alias/truncation/replacement attempts with an unchanged digest, and hostile repository symlinks; an oversized repository fails closed without leaving storage. - Live vendor probes read the mounted schema and return its value through each adapter's documented channel. - Run the gate in the Agent isolation workflow, keep it out of the parallel main CI run, and document the T9 matrix and F/G handoff. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/agent-isolation.yml | 2 +- .github/workflows/ci.yml | 2 +- agents/policy.ts | 44 ++++++++++--- docs/implementation/agent-isolation.md | 86 ++++++++++++++++++++++++++ test/agent-container.test.ts | 38 +++++++++++- test/agent-gate.test.ts | 69 +++++++++++++++++++++ test/agent-supervisor.test.ts | 19 ++++-- 7 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 docs/implementation/agent-isolation.md create mode 100644 test/agent-gate.test.ts diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index 5cd1af0..ff86287 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 test/agent-proxy.test.ts test/agent-adapter.test.ts test/agent-supervisor.test.ts test/agent-output.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 test/agent-adapter.test.ts test/agent-supervisor.test.ts test/agent-output.test.ts test/agent-gate.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc5a962..46839eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,6 @@ jobs: - run: npm run typecheck # 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 --exclude test/agent-adapter.test.ts --exclude test/agent-supervisor.test.ts + - run: npm test -- --exclude test/agent-container.test.ts --exclude test/agent-network.test.ts --exclude test/agent-adapter.test.ts --exclude test/agent-supervisor.test.ts --exclude test/agent-gate.test.ts - run: npx playwright install --with-deps chromium - run: npm run test:browser diff --git a/agents/policy.ts b/agents/policy.ts index 0d1ebaf..e5d14a7 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -93,7 +93,17 @@ export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | | 'infinite-stdout' | 'infinite-stderr' | 'infinite-mixed' | 'ignore-term' | 'symlink-output' | 'oversized-output' | 'fifo-output' | 'invalid-utf8-output' | 'invalid-utf8-stderr' | 'truncated-utf8-stderr' | 'replace-output-directory' - | 'nonzero-output' | 'duplicate-protocol' | 'newline-free-deferred-output'; + | 'nonzero-output' | 'duplicate-protocol' | 'newline-free-deferred-output' | 'scratch-capacity' | 'metadata-alias' + | 'hostile-repo'; + +// `set -e` ignores a failing `! command`, so a negated check could never fail a probe. `deny` exits instead when a +// forbidden action succeeds, and names the breach. +const deny = 'deny() { if "$@" 2>/dev/null; then echo "isolation breach: $*" >&2; exit 1; fi; }; '; +// Fill a scratch directory past its byte and inode limits; each must stop well before the fill completes. +const scratchBounded = (directory: string, megabytes: number, files: number) => + `deny dd if=/dev/zero of="${directory}/overflow" bs=1M count=${megabytes}; rm -f "${directory}/overflow"; ` + + `mkdir "${directory}/many"; i=0; while touch "${directory}/many/$i" 2>/dev/null; do i=$((i+1)); ` + + `test "$i" -lt ${files}; done; test "$i" -lt ${files}; rm -rf "${directory}/many"; `; /** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { @@ -102,17 +112,17 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 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; ' + : `${deny}set -eu; deny touch /work/${phase}.txt; test ! -e /work/${phase}.txt`, + 'read-only-isolation': `${deny}set -eu; test "$(id -u)" = 10001; test "$(git status --porcelain)" = ""; ` + + 'test -z "${HOST_SECRET_SENTINEL:-}"; deny touch /work/forbidden; deny 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; ' + capacity: `${deny}set -eu; deny dd if=/dev/zero of=/work/overflow bs=1M count=32; 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', + metadata: `${deny}set -eu; deny touch /work/.git/forbidden; deny ln /work/.git/HEAD /work/metadata-link; ` + + 'deny mv /work/.git /work/replaced; 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', @@ -131,6 +141,26 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 'nonzero-output': 'printf encoded-output; exit 7', 'duplicate-protocol': "printf '\\036CODEBOOST_START:00000000-0000-0000-0000-000000000000\\036\\n' >&2", 'newline-free-deferred-output': "printf captured > /run/codeboost-output/final.txt; printf trailing-diagnostic >&2", + // Every agent-writable scratch area enforces both its byte and inode ceilings; the control area is not writable. + 'scratch-capacity': `${deny}set -eu; ${scratchBounded('/tmp', 64, 10000)}${scratchBounded('$HOME', 4, 1000)}` + + 'if [ -n "${CODEX_HOME:-}" ]; then ' + + `${scratchBounded('$CODEX_HOME', 16, 2000)}${scratchBounded('/run/codeboost-output', 64, 1000)}fi; ` + + 'if [ -d /run/codeboost-control ]; then deny touch /run/codeboost-control/forged; fi; printf scratch-bounded', + // Hard links, symlink aliases, truncation and replacement all fail, and the metadata digest is unchanged. + 'metadata-alias': `${deny}set -eu; ` + + 'digest() { (cd /work/.git && find . -type f -exec sha256sum {} + | sort | sha256sum); }; before=$(digest); ' + + 'object=$(find /work/.git/objects -type f | head -n 1); test -n "$object"; ' + + 'for target in /work /tmp "$HOME"; do deny ln /work/.git/config "$target/config-link"; ' + + 'deny ln "$object" "$target/object-link"; done; ' + + 'ln -s /work/.git/config /tmp/config-alias; ln -s "$object" /tmp/object-alias; ' + + "deny sh -c 'printf x >> /tmp/config-alias'; deny sh -c 'printf x >> /tmp/object-alias'; " + + "deny sh -c ': > /work/.git/config'; deny truncate -s 0 /work/.git/config; " + + 'deny rm -rf /work/.git; deny mv /work/.git /work/replaced; deny mv /work/.git /tmp/replaced; ' + + 'test "$(digest)" = "$before"; git status --porcelain > /dev/null; printf metadata-unchanged', + // Repository symlinks that point outside the checkout arrive as links, never as their host targets. + 'hostile-repo': `${deny}set -eu; test -L /work/escape; test -L /work/escape-dir; ` + + 'test "$(git status --porcelain)" = ""; deny grep -Rqs codeboost-host-secret /work /tmp "$HOME"; ' + + 'printf hostile-repo-contained', }; return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/docs/implementation/agent-isolation.md b/docs/implementation/agent-isolation.md new file mode 100644 index 0000000..847b8bd --- /dev/null +++ b/docs/implementation/agent-isolation.md @@ -0,0 +1,86 @@ +# D5 agent isolation gate + +This page describes the combined gate for lane D. The gate is the set of real-Docker +tests that must pass before lanes F and G may run agents in production. It also +states what those lanes must do when they call the isolation boundary. + +## Run the gate + +The gate needs a running Docker daemon. Run the suites one file at a time, because +they share one image tag and one daemon: + +```bash +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 test/agent-adapter.test.ts test/agent-supervisor.test.ts test/agent-output.test.ts test/agent-gate.test.ts +``` + +The `Agent isolation` workflow runs the same command. The main `CI` workflow skips +the Docker suites so that they never run in parallel. + +The live vendor probes need real credentials, so CI does not run them. To run them, +set `CODEBOOST_RUN_AUTH_PROBES=1`, `CODEBOOST_CODEX_AUTH_FILE` (a Codex `auth.json` +path) and `CLAUDE_CODE_OAUTH_TOKEN`. Do not put credentials in an issue, a pull +request or chat. + +## What the gate proves + +Each row is a T9 requirement for the Docker suite. The suite fails if any row fails. + +| Requirement | Tests | +| --- | --- | +| Isolation holds: non-root, no capabilities, read-only root, no host paths or secrets, vendor-only egress | `agent-container`: read-only isolation, lockdown and mount validation; `agent-network`: egress and DNS | +| A read-only phase cannot write `/work` | `agent-container`: phase worktree for all five phases | +| Planning and questions cannot run a process | `agent-policy`: tool sets exclude the command tool, and command dispatch refuses these phases | +| Task and scratch byte and inode limits hold | `agent-container`: task capacity; scratch capacity for Codex and Claude (`/tmp`, `HOME`, `CODEX_HOME`, output directory) | +| Hard links and alias writes from `.git/config` and objects fail, and metadata stays unchanged | `agent-container`: metadata alias probe in planning, review and execute, with a digest of `.git` before and after | +| Mountpoint replacement fails | `agent-container`: metadata and metadata alias probes (`mv` and `rm -rf` of `.git`) | +| Both vendor startup probes read the schema and return bounded valid output through their documented channel | `agent-supervisor` live probes: Codex through its output file, Claude through its stdout envelope (credentials required) | +| Hostile input stays inside the boundary | `agent-container`: repository symlinks to host files, oversized repositories fail closed; `agent-policy`: option-like prompts; `agent-proxy`: hostile CONNECT traffic; `agent-supervisor`: hostile output | + +## Why the gate can fail + +A test that cannot fail proves nothing. `agent-gate` runs each negative probe from +production in a container that is missing one protection. It then checks that the +probe reports that exact breach. The cases are writable Git metadata, a writable +worktree in a read-only phase, an unbounded task filesystem, unbounded scratch, and +secret content in the worktree. + +Probe scripts must use the `deny` helper for actions that must fail. Do not write +`! command` in a probe: `set -e` ignores a negated command, so the probe would +continue and report success even when the forbidden action worked. Before D5, the +metadata, read-only isolation and capacity probes had this defect. + +## Handoff to lanes F and G + +Use only these entry points to run an agent: + +1. `createTaskClone` creates a committed, standalone staging clone. +2. `prepareTaskFilesystems` copies that clone into bounded task storage. Call + `removeTaskFilesystems` when the task ends. +3. `captureInvocation` freezes the request. Capture each attempt ID once. A new + attempt needs a new attempt ID. +4. `startCodexInvocation` or `startClaudeInvocation` runs the agent and returns a + handle. Pass the vendor credential only as the function argument. + +The boundary guarantees the following: + +- The profile is immutable, and every launch revalidates it against Docker. +- Tools are limited by phase. Planning and questions can only read, list and search. +- Web search and MCP are off, stdin is closed, and network traffic reaches only the + vendor hosts. +- Output is bounded and decoded as strict UTF-8. Invalid output fails as + `capture-failure`. +- The invocation deadline bounds every launch. Cleanup still runs after the deadline. + +The caller must do the following: + +- Keep ownership until `settled` resolves. It resolves only after the container has + stopped and its cleanup has finished. The supervisor retries cleanup until then. +- Call `cancel` to stop an invocation. The first stop reason is kept. +- Treat `stopReason` as the result of the invocation. A missing `stopReason` means + the agent finished normally. + +## Limits of this gate + +- CI does not run the live vendor probes. Run them locally with credentials before + a release that changes the image, the adapters or the prompts. +- T9 as a whole is complete only when lane F runs every suite in required CI (F6). diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 0320c88..aed249b 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -27,7 +27,8 @@ const docker = (...args: string[]) => execFileSync('docker', args, { encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], }).trim(); -function fixture(options: { limits?: Parameters[1]; historyBytes?: number } = {}) { +function fixture(options: { limits?: Parameters[1]; historyBytes?: number; + hostile?: (source: string, root: string) => void } = {}) { const root = mkdtempSync(join(tmpdir(), 'agent-container-')); roots.push(root); const source = join(root, 'source'), staging = join(root, 'staging'), input = join(root, 'input'); mkdirSync(source); mkdirSync(staging); mkdirSync(input); @@ -38,6 +39,7 @@ function fixture(options: { limits?: Parameters[1 git(source, 'add', '.'); git(source, 'commit', '-m', 'history'); rmSync(join(source, 'history.bin')); } + options.hostile?.(source, root); writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '-A'); git(source, 'commit', '-m', 'baseline'); writeFileSync(join(input, 'schema.json'), '{"probe":"codeboost-schema-marker"}\n'); chmodSync(join(input, 'schema.json'), 0o444); chmodSync(input, 0o555); @@ -140,6 +142,40 @@ describe('real Docker agent isolation', () => { expect(args).toContain('--cgroupns=private'); }, 60_000); + it.each(['planning', 'review', 'execute'] as const)( + 'keeps Git metadata unchanged under link, alias, truncation and replacement attempts during %s', phase => { + expect(runContainer(profile(fixture(), phase, 'metadata-alias'))).toBe('metadata-unchanged'); + }, 60_000); + + it('enforces byte and inode ceilings on every Codex scratch area', () => { + expect(runContainer(profile(fixture(), 'execute', 'scratch-capacity'))).toBe('scratch-bounded'); + }, 120_000); + + it('enforces byte and inode ceilings on every Claude scratch area', () => { + const placeholder = 'offline-placeholder-token'; + const claude = profile(fixture(), 'execute', 'scratch-capacity', { vendor: 'claude', claudeToken: placeholder }); + expect(runContainer(claude, 60_000, { CLAUDE_CODE_OAUTH_TOKEN: placeholder })).toBe('scratch-bounded'); + }, 120_000); + + it('seeds repository symlinks that point at host files as links, without their targets', () => { + const data = fixture({ hostile: (source, root) => { + writeFileSync(join(root, 'host-only.txt'), 'codeboost-host-secret\n'); + symlinkSync(join(root, 'host-only.txt'), join(source, 'escape')); + symlinkSync(root, join(source, 'escape-dir')); + } }); + expect(runContainer(profile(data, 'execute', 'hostile-repo'))).toBe('hostile-repo-contained'); + }, 60_000); + + it('fails closed without leaving storage when a repository exceeds its allocation', () => { + const owned = () => [docker('volume', 'ls', '--quiet', '--filter', 'label=io.codeboost.allocation'), + docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.allocation')].join('\n').split('\n').filter(Boolean); + const before = new Set(owned()); + expect(() => fixture({ historyBytes: 4 * 1024 * 1024, limits: { + workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 1024 * 1024, metadataInodes: 512, + } })).toThrow(); + expect(owned().filter(id => !before.has(id))).toEqual([]); + }, 60_000); + it('persists execution changes while replacing HOME and scratch for each invocation', () => { const data = fixture(); expect(runContainer(profile(data, 'execute', 'persist-write'))).toBe('first'); diff --git a/test/agent-gate.test.ts b/test/agent-gate.test.ts new file mode 100644 index 0000000..72729f5 --- /dev/null +++ b/test/agent-gate.test.ts @@ -0,0 +1,69 @@ +import { spawnSync } from 'node:child_process'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { captureInvocation, type Phase } from '../agents/contract.ts'; +import { buildAgentImage } from '../agents/container/image.ts'; +import { createIsolationProbeCommand, createPhasePolicy, type IsolationProbe } from '../agents/policy.ts'; + +// The gate is only meaningful if its probes fail when isolation breaks. Each case runs the exact production probe +// script in a container deliberately built without one protection, bypassing the profile and its validator, and +// requires the probe to report that breach instead of its success marker. The same probes pass under real profiles +// in agent-container.test.ts, so each failure here is caused by the injected breach. + +let imageId = '', attempt = 0; +const probeScript = (phase: Phase, probe: IsolationProbe) => { + const policy = createPhasePolicy(captureInvocation({ + clone: { id: 'gate-clone', taskId: 'gate-task', directory: '/tmp/gate', head: 'a'.repeat(40) }, + vendor: 'codex', phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['git', 'status']], + deadline: Date.now() + 60_000, attemptId: `gate-${phase}-${probe}-${++attempt}`, + context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 }, + })); + const argv = createIsolationProbeCommand(policy, probe).argv; + expect(argv.slice(0, 2)).toEqual(['sh', '-c']); + return argv[2]!; +}; +// A committed repository whose metadata lives on its own filesystem, as in a real task container. +const seedRepository = 'git init -q /work && git -C /work -c user.name=gate -c user.email=gate@example.com ' + + 'commit -q --allow-empty -m seed'; +const runBroken = (mounts: readonly string[], script: string) => spawnSync('docker', ['run', '--rm', '--network=none', + '--user', '10001:10001', '--env', 'HOME=/home/codeboost', '--workdir', '/work', ...mounts, + '--tmpfs', '/home/codeboost:rw,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', + '--entrypoint', 'sh', imageId, '-c', `${seedRepository} && ${script}`], +{ encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] }); +const tmpfs = (target: string, options: string) => ['--tmpfs', `${target}:rw,uid=10001,gid=10001,${options}`]; +const bounded = tmpfs('/work', 'size=16m,nr_inodes=512'); +const writableMetadata = tmpfs('/work/.git', 'size=16m,nr_inodes=512'); +const boundedScratch = tmpfs('/tmp', 'size=32m,nr_inodes=4096'); + +beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000); + +describe('isolation gate detects breaches', () => { + it.each([ + ['writable Git metadata', 'execute', 'metadata', [...bounded, ...writableMetadata, ...boundedScratch], + 'isolation breach: touch /work/.git/forbidden'], + ['writable Git metadata under alias attacks', 'execute', 'metadata-alias', + [...bounded, ...writableMetadata, ...boundedScratch], 'isolation breach: sh -c printf x >> /tmp/config-alias'], + ['a writable worktree in a read-only phase', 'planning', 'read-only-isolation', + [...bounded, ...writableMetadata, ...boundedScratch], 'isolation breach: touch /work/forbidden'], + ['a writable worktree for the phase probe', 'review', 'phase-worktree', + [...bounded, ...writableMetadata, ...boundedScratch], 'isolation breach: touch /work/review.txt'], + ['an unbounded task filesystem', 'execute', 'capacity', + [...tmpfs('/work', 'size=256m'), ...writableMetadata, ...boundedScratch], + 'isolation breach: dd if=/dev/zero of=/work/overflow'], + ['unbounded scratch', 'execute', 'scratch-capacity', + [...bounded, ...writableMetadata, ...tmpfs('/tmp', 'size=256m')], 'isolation breach: dd if=/dev/zero of=/tmp/overflow'], + ] as const)('fails the probe for %s', (_label, phase, probe, mounts, breach) => { + const result = runBroken(mounts, probeScript(phase, probe)); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stderr).toContain(breach); + }, 180_000); + + it('fails the hostile-repository probe when secret content reaches the task filesystem', () => { + // Stand-in for a seeder that followed a symlink: the secret text is committed into /work behind the link names. + const leaked = 'printf codeboost-host-secret > /work/leak && ln -s /work/leak /work/escape && ln -s /tmp /work/escape-dir ' + + '&& git -C /work add -A && git -C /work -c user.name=gate -c user.email=gate@example.com commit -q -m leak && '; + const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], + leaked + probeScript('execute', 'hostile-repo')); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stderr).toContain('isolation breach: grep -Rqs codeboost-host-secret'); + }, 120_000); +}); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 7204045..351075d 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -28,7 +28,7 @@ function fixture() { mkdirSync(source); mkdirSync(staging); mkdirSync(input); git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com'); writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline'); - writeFileSync(join(input, 'schema.json'), '{}\n'); chmodSync(join(input, 'schema.json'), 0o444); chmodSync(input, 0o555); + writeFileSync(join(input, 'schema.json'), '{"probe":"codeboost-adapter-schema-marker"}\n'); chmodSync(join(input, 'schema.json'), 0o444); chmodSync(input, 0o555); const clone = createTaskClone({ source, parent: staging, taskId: 'supervisor', head: git(source, 'rev-parse', 'HEAD') }); const filesystems = prepareTaskFilesystems(clone, { workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, @@ -373,15 +373,21 @@ describe('container invocation supervisor', () => { }, 60_000); if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { + const schemaPrompt = 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, ' + + 'without quotes or Markdown formatting.'; + // Tolerate one wrapping pair of backticks or quotes, but nothing else around the value. + const schemaValue = (output: string) => output.trim().replace(/^(`+|"|')([^]*)\1$/, '$2').trim(); + it('runs the production Codex adapter and collects its bounded output file', async () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); const result = await startCodexInvocation({ invocation: invocation(data, 'live-codex', 6 * 60_000), filesystems: data.filesystems, inputDirectory: data.input, imageId, - prompt: 'Reply only with this exact marker: codeboost-adapter-marker' }, authFile).settled; + prompt: schemaPrompt }, authFile).settled; expect(result.stopReason, result.stderr).toBeUndefined(); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('codeboost-adapter-marker'); + // Codex returns through its bounded output file; the value can only come from the mounted schema. + expect(schemaValue(result.stdout)).toBe('codeboost-adapter-schema-marker'); }, 8 * 60_000); it('runs the production Claude adapter and parses its bounded envelope', async () => { @@ -389,10 +395,11 @@ describe('container invocation supervisor', () => { if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); const result = await startClaudeInvocation({ invocation: invocation(data, 'live-claude', 6 * 60_000, 'claude'), filesystems: data.filesystems, inputDirectory: data.input, imageId, - prompt: 'Reply only with this exact marker: codeboost-adapter-marker' }, token).settled; - expect(result.stopReason).toBeUndefined(); + prompt: schemaPrompt }, token).settled; + expect(result.stopReason, result.stderr).toBeUndefined(); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('codeboost-adapter-marker'); + // Claude returns through its bounded stdout envelope; the value can only come from the mounted schema. + expect(schemaValue(result.stdout)).toBe('codeboost-adapter-schema-marker'); }, 8 * 60_000); } }); From a32e6a9bac8b861967c98e3f6d41423df16eec0a Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 15:14:04 -0700 Subject: [PATCH 02/11] Make scratch and hostile-repository probes fail closed - scratch-capacity required only that fills fail, so an unwritable area or a Codex container missing CODEX_HOME passed vacuously. Each fill now must write before hitting its limit, and a Codex container must have its Codex scratch areas. - hostile-repo searched with grep -R, which follows symlinks, so a hostile link to / or a loop could walk the whole container. Search without following links and check the link targets separately. Co-Authored-By: Claude Opus 5.5 --- agents/policy.ts | 20 ++++++++++++-------- test/agent-container.test.ts | 5 +++++ test/agent-gate.test.ts | 14 +++++++++++--- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/agents/policy.ts b/agents/policy.ts index e5d14a7..9fccb4e 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -99,11 +99,13 @@ export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | // `set -e` ignores a failing `! command`, so a negated check could never fail a probe. `deny` exits instead when a // forbidden action succeeds, and names the breach. const deny = 'deny() { if "$@" 2>/dev/null; then echo "isolation breach: $*" >&2; exit 1; fi; }; '; -// Fill a scratch directory past its byte and inode limits; each must stop well before the fill completes. +// Fill a scratch directory past its byte and inode limits. Each fill must stop early, and must have written first, so +// an unwritable or missing directory fails the probe instead of passing it vacuously. const scratchBounded = (directory: string, megabytes: number, files: number) => - `deny dd if=/dev/zero of="${directory}/overflow" bs=1M count=${megabytes}; rm -f "${directory}/overflow"; ` - + `mkdir "${directory}/many"; i=0; while touch "${directory}/many/$i" 2>/dev/null; do i=$((i+1)); ` - + `test "$i" -lt ${files}; done; test "$i" -lt ${files}; rm -rf "${directory}/many"; `; + `deny dd if=/dev/zero of="${directory}/overflow" bs=1M count=${megabytes}; test -s "${directory}/overflow"; ` + + `rm -f "${directory}/overflow"; mkdir "${directory}/many"; i=0; ` + + `while touch "${directory}/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt ${files}; done; ` + + `test "$i" -gt 0; test "$i" -lt ${files}; rm -rf "${directory}/many"; `; /** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */ export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand { @@ -143,7 +145,7 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 'newline-free-deferred-output': "printf captured > /run/codeboost-output/final.txt; printf trailing-diagnostic >&2", // Every agent-writable scratch area enforces both its byte and inode ceilings; the control area is not writable. 'scratch-capacity': `${deny}set -eu; ${scratchBounded('/tmp', 64, 10000)}${scratchBounded('$HOME', 4, 1000)}` - + 'if [ -n "${CODEX_HOME:-}" ]; then ' + + 'if [ "${CODEBOOST_VENDOR:-}" = codex ]; then test -n "${CODEX_HOME:-}"; ' + `${scratchBounded('$CODEX_HOME', 16, 2000)}${scratchBounded('/run/codeboost-output', 64, 1000)}fi; ` + 'if [ -d /run/codeboost-control ]; then deny touch /run/codeboost-control/forged; fi; printf scratch-bounded', // Hard links, symlink aliases, truncation and replacement all fail, and the metadata digest is unchanged. @@ -157,10 +159,12 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio + "deny sh -c ': > /work/.git/config'; deny truncate -s 0 /work/.git/config; " + 'deny rm -rf /work/.git; deny mv /work/.git /work/replaced; deny mv /work/.git /tmp/replaced; ' + 'test "$(digest)" = "$before"; git status --porcelain > /dev/null; printf metadata-unchanged', - // Repository symlinks that point outside the checkout arrive as links, never as their host targets. + // Repository symlinks that point outside the checkout arrive as links, never as their host targets. The search does + // not follow links, so a hostile link to `/` or a loop cannot make it walk the whole container; the link targets + // are checked separately. 'hostile-repo': `${deny}set -eu; test -L /work/escape; test -L /work/escape-dir; ` - + 'test "$(git status --porcelain)" = ""; deny grep -Rqs codeboost-host-secret /work /tmp "$HOME"; ' - + 'printf hostile-repo-contained', + + 'test "$(git status --porcelain)" = ""; deny grep -rqs codeboost-host-secret /work /tmp "$HOME"; ' + + 'deny cat /work/escape; deny ls -A /work/escape-dir/; printf hostile-repo-contained', }; 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 aed249b..ad53dfa 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -162,8 +162,13 @@ describe('real Docker agent isolation', () => { writeFileSync(join(root, 'host-only.txt'), 'codeboost-host-secret\n'); symlinkSync(join(root, 'host-only.txt'), join(source, 'escape')); symlinkSync(root, join(source, 'escape-dir')); + // Links a traversal must not follow: the container root and a self-reference. + symlinkSync('/', join(source, 'root-link')); + symlinkSync('.', join(source, 'loop')); } }); + const started = performance.now(); expect(runContainer(profile(data, 'execute', 'hostile-repo'))).toBe('hostile-repo-contained'); + expect(performance.now() - started).toBeLessThan(30_000); }, 60_000); it('fails closed without leaving storage when a repository exceeds its allocation', () => { diff --git a/test/agent-gate.test.ts b/test/agent-gate.test.ts index 72729f5..723bac2 100644 --- a/test/agent-gate.test.ts +++ b/test/agent-gate.test.ts @@ -24,8 +24,8 @@ const probeScript = (phase: Phase, probe: IsolationProbe) => { // A committed repository whose metadata lives on its own filesystem, as in a real task container. const seedRepository = 'git init -q /work && git -C /work -c user.name=gate -c user.email=gate@example.com ' + 'commit -q --allow-empty -m seed'; -const runBroken = (mounts: readonly string[], script: string) => spawnSync('docker', ['run', '--rm', '--network=none', - '--user', '10001:10001', '--env', 'HOME=/home/codeboost', '--workdir', '/work', ...mounts, +const runBroken = (mounts: readonly string[], script: string, env: readonly string[] = []) => spawnSync('docker', ['run', + '--rm', '--network=none', '--user', '10001:10001', '--env', 'HOME=/home/codeboost', ...env, '--workdir', '/work', ...mounts, '--tmpfs', '/home/codeboost:rw,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', '--entrypoint', 'sh', imageId, '-c', `${seedRepository} && ${script}`], { encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -57,6 +57,14 @@ describe('isolation gate detects breaches', () => { expect(result.stderr).toContain(breach); }, 180_000); + it('fails the scratch probe for a Codex container whose Codex scratch areas are missing', () => { + // Missing scratch areas must fail the probe, not skip their checks and report the container as bounded. + const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], + probeScript('execute', 'scratch-capacity'), ['--env', 'CODEBOOST_VENDOR=codex']); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stdout).not.toContain('scratch-bounded'); + }, 120_000); + it('fails the hostile-repository probe when secret content reaches the task filesystem', () => { // Stand-in for a seeder that followed a symlink: the secret text is committed into /work behind the link names. const leaked = 'printf codeboost-host-secret > /work/leak && ln -s /work/leak /work/escape && ln -s /tmp /work/escape-dir ' @@ -64,6 +72,6 @@ describe('isolation gate detects breaches', () => { const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], leaked + probeScript('execute', 'hostile-repo')); expect(result.status, result.stderr).not.toBe(0); - expect(result.stderr).toContain('isolation breach: grep -Rqs codeboost-host-secret'); + expect(result.stderr).toContain('isolation breach: grep -rqs codeboost-host-secret'); }, 120_000); }); From 9bd4939c1d974597ec2f5daae61e300fb56af891 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 15:35:16 -0700 Subject: [PATCH 03/11] Require exact live vendor values and widen gate breach coverage - The container-level Codex live probe now reads the mounted schema instead of echoing a fixed marker, and the Claude container probe and both live adapter probes compare the trimmed value exactly, with no allowance for wrapping quotes or backticks. - Add gate breach cases for an unbounded HOME and for a repository link that resolves inside the container, so the hostile-repository link target checks can fail. Co-Authored-By: Claude Opus 5.5 --- test/agent-container.test.ts | 11 +++++------ test/agent-gate.test.ts | 16 +++++++++++++++- test/agent-supervisor.test.ts | 4 ++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index ad53dfa..bcbf064 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -593,11 +593,12 @@ describe('real Docker agent isolation', () => { 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', policy => createCodexCommand(policy, - 'Reply only with this exact marker: codeboost-schema-marker'), + 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, without quotes or Markdown formatting.'), { authProbe: true, codexAuthFile: authFile, deadlineMs: 5 * 60_000 }); - // The production launch path: create, validate, start and remove. + // The production launch path: create, validate, start and remove. Raw stdout can carry more than the final + // message, so the value must appear as a complete line; the adapter probe checks the exact file channel. const output = runContainer(authProfile, 5 * 60_000); - expect(output).toContain('codeboost-schema-marker'); + expect(output.split('\n').map(line => line.trim())).toContain('codeboost-schema-marker'); }, 6 * 60_000); it('runs the authenticated Claude startup path with only its OAuth token', () => { @@ -610,9 +611,7 @@ describe('real Docker agent isolation', () => { 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. - const value = envelope.result?.trim().replace(/^(`+|"|')([^]*)\1$/, '$2').trim(); - expect(value).toBe('codeboost-schema-marker'); + expect(envelope.result?.trim()).toBe('codeboost-schema-marker'); }, 6 * 60_000); } }); diff --git a/test/agent-gate.test.ts b/test/agent-gate.test.ts index 723bac2..a18dd45 100644 --- a/test/agent-gate.test.ts +++ b/test/agent-gate.test.ts @@ -26,7 +26,8 @@ const seedRepository = 'git init -q /work && git -C /work -c user.name=gate -c u + 'commit -q --allow-empty -m seed'; const runBroken = (mounts: readonly string[], script: string, env: readonly string[] = []) => spawnSync('docker', ['run', '--rm', '--network=none', '--user', '10001:10001', '--env', 'HOME=/home/codeboost', ...env, '--workdir', '/work', ...mounts, - '--tmpfs', '/home/codeboost:rw,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', + ...(mounts.some(mount => mount.startsWith('/home/codeboost:')) ? [] + : ['--tmpfs', '/home/codeboost:rw,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700']), '--entrypoint', 'sh', imageId, '-c', `${seedRepository} && ${script}`], { encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] }); const tmpfs = (target: string, options: string) => ['--tmpfs', `${target}:rw,uid=10001,gid=10001,${options}`]; @@ -49,6 +50,9 @@ describe('isolation gate detects breaches', () => { ['an unbounded task filesystem', 'execute', 'capacity', [...tmpfs('/work', 'size=256m'), ...writableMetadata, ...boundedScratch], 'isolation breach: dd if=/dev/zero of=/work/overflow'], + ['an unbounded HOME', 'execute', 'scratch-capacity', + [...bounded, ...writableMetadata, ...boundedScratch, ...tmpfs('/home/codeboost', 'size=256m,mode=0700')], + 'isolation breach: dd if=/dev/zero of=/home/codeboost/overflow'], ['unbounded scratch', 'execute', 'scratch-capacity', [...bounded, ...writableMetadata, ...tmpfs('/tmp', 'size=256m')], 'isolation breach: dd if=/dev/zero of=/tmp/overflow'], ] as const)('fails the probe for %s', (_label, phase, probe, mounts, breach) => { @@ -65,6 +69,16 @@ describe('isolation gate detects breaches', () => { expect(result.stdout).not.toContain('scratch-bounded'); }, 120_000); + it('fails the hostile-repository probe when a repository link resolves inside the container', () => { + // A link that resolves is readable through the checkout, whatever it contains. + const linked = 'ln -s /etc/hostname /work/escape && ln -s /nonexistent /work/escape-dir && git -C /work add -A ' + + '&& git -C /work -c user.name=gate -c user.email=gate@example.com commit -q -m links && '; + const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], + linked + probeScript('execute', 'hostile-repo')); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stderr).toContain('isolation breach: cat /work/escape'); + }, 120_000); + it('fails the hostile-repository probe when secret content reaches the task filesystem', () => { // Stand-in for a seeder that followed a symlink: the secret text is committed into /work behind the link names. const leaked = 'printf codeboost-host-secret > /work/leak && ln -s /work/leak /work/escape && ln -s /tmp /work/escape-dir ' diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 351075d..45d2115 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -375,8 +375,8 @@ describe('container invocation supervisor', () => { if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { const schemaPrompt = 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, ' + 'without quotes or Markdown formatting.'; - // Tolerate one wrapping pair of backticks or quotes, but nothing else around the value. - const schemaValue = (output: string) => output.trim().replace(/^(`+|"|')([^]*)\1$/, '$2').trim(); + // Exact value only: the prompt forbids quotes and formatting, so anything around the value fails the probe. + const schemaValue = (output: string) => output.trim(); it('runs the production Codex adapter and collects its bounded output file', async () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; From 5504b32a77b4362935809c97110d650fe5a0b378 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 15:53:01 -0700 Subject: [PATCH 04/11] Cover Codex scratch areas and read-only phases in the gate self-tests - Run scratch breach cases in a Codex environment with an unbounded CODEX_HOME and an unbounded output directory, so removing or breaking the Codex-only scratch checks fails the gate. - Run the phase write-denial breach case in planning, questions and review, and the metadata alias breach case in planning. Co-Authored-By: Claude Opus 5.5 --- test/agent-gate.test.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/test/agent-gate.test.ts b/test/agent-gate.test.ts index a18dd45..2630c7d 100644 --- a/test/agent-gate.test.ts +++ b/test/agent-gate.test.ts @@ -45,8 +45,8 @@ describe('isolation gate detects breaches', () => { [...bounded, ...writableMetadata, ...boundedScratch], 'isolation breach: sh -c printf x >> /tmp/config-alias'], ['a writable worktree in a read-only phase', 'planning', 'read-only-isolation', [...bounded, ...writableMetadata, ...boundedScratch], 'isolation breach: touch /work/forbidden'], - ['a writable worktree for the phase probe', 'review', 'phase-worktree', - [...bounded, ...writableMetadata, ...boundedScratch], 'isolation breach: touch /work/review.txt'], + ['writable Git metadata under alias attacks in a read-only phase', 'planning', 'metadata-alias', + [...bounded, ...writableMetadata, ...boundedScratch], 'isolation breach: sh -c printf x >> /tmp/config-alias'], ['an unbounded task filesystem', 'execute', 'capacity', [...tmpfs('/work', 'size=256m'), ...writableMetadata, ...boundedScratch], 'isolation breach: dd if=/dev/zero of=/work/overflow'], @@ -61,6 +61,29 @@ describe('isolation gate detects breaches', () => { expect(result.stderr).toContain(breach); }, 180_000); + it.each(['planning', 'questions', 'review'] as const)('fails the phase probe for a writable worktree in %s', phase => { + const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], probeScript(phase, 'phase-worktree')); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stderr).toContain(`isolation breach: touch /work/${phase}.txt`); + }, 120_000); + + // Codex-only scratch areas are checked only in a Codex container, so these cases supply that environment and make + // exactly one Codex area unbounded. + const codexEnvironment = ['--env', 'CODEBOOST_VENDOR=codex', '--env', 'CODEX_HOME=/run/codeboost-auth/codex']; + const codexHome = (options: string) => tmpfs('/run/codeboost-auth/codex', `${options},mode=0700`); + const codexOutput = (options: string) => tmpfs('/run/codeboost-output', `${options},mode=0700`); + it.each([ + ['CODEX_HOME', [...codexHome('size=256m'), ...codexOutput('size=20m,nr_inodes=64')], + 'isolation breach: dd if=/dev/zero of=/run/codeboost-auth/codex/overflow'], + ['Codex output directory', [...codexHome('size=4m,nr_inodes=256'), ...codexOutput('size=256m')], + 'isolation breach: dd if=/dev/zero of=/run/codeboost-output/overflow'], + ] as const)('fails the scratch probe for an unbounded %s', (_label, codexMounts, breach) => { + const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch, ...codexMounts], + probeScript('execute', 'scratch-capacity'), codexEnvironment); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stderr).toContain(breach); + }, 180_000); + it('fails the scratch probe for a Codex container whose Codex scratch areas are missing', () => { // Missing scratch areas must fail the probe, not skip their checks and report the container as bounded. const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], From 49ef6276597547ab111491ac4c866230aaece310 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 16:06:06 -0700 Subject: [PATCH 05/11] Discard forbidden command output in probe denials deny suppressed only stderr, so a forbidden read that unexpectedly succeeded (such as cat through a repository link) copied its data into the invocation output before the breach was reported. Discard both streams of the attempted command; the gate now requires empty stdout for a resolvable repository link. Co-Authored-By: Claude Opus 5.5 --- agents/policy.ts | 5 +++-- test/agent-gate.test.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/agents/policy.ts b/agents/policy.ts index 9fccb4e..ed1580c 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -97,8 +97,9 @@ export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | | 'hostile-repo'; // `set -e` ignores a failing `! command`, so a negated check could never fail a probe. `deny` exits instead when a -// forbidden action succeeds, and names the breach. -const deny = 'deny() { if "$@" 2>/dev/null; then echo "isolation breach: $*" >&2; exit 1; fi; }; '; +// forbidden action succeeds, and names the breach. Both streams of the attempted command are discarded, so a breach +// that succeeds (such as reading a host file) cannot copy its data into the invocation output. +const deny = 'deny() { if "$@" >/dev/null 2>&1; then echo "isolation breach: $*" >&2; exit 1; fi; }; '; // Fill a scratch directory past its byte and inode limits. Each fill must stop early, and must have written first, so // an unwritable or missing directory fails the probe instead of passing it vacuously. const scratchBounded = (directory: string, megabytes: number, files: number) => diff --git a/test/agent-gate.test.ts b/test/agent-gate.test.ts index 2630c7d..6bcac87 100644 --- a/test/agent-gate.test.ts +++ b/test/agent-gate.test.ts @@ -100,6 +100,8 @@ describe('isolation gate detects breaches', () => { linked + probeScript('execute', 'hostile-repo')); expect(result.status, result.stderr).not.toBe(0); expect(result.stderr).toContain('isolation breach: cat /work/escape'); + // The probe reports only its fixed diagnostic; the readable target's contents never reach the output. + expect(result.stdout).toBe(''); }, 120_000); it('fails the hostile-repository probe when secret content reaches the task filesystem', () => { From 36ed738f484b8bab4849e1c64d4911d6536487f9 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 16:22:52 -0700 Subject: [PATCH 06/11] Refuse repository links that leave the checkout The seeder copies repository symlinks as links, so an absolute link such as root-link -> / let a path-restricted agent tool read container files outside the checkout, including process environments that hold vendor credentials. prepareTaskFilesystems now refuses, before creating storage, any link with an absolute target or one that leaves the checkout lexically or through a chain of links (checked with the POSIX native realpath; the JavaScript realpath cancels .. before following links and misses chained escapes). Links inside the checkout, loops and not-yet-existing targets still work. The hostile-repo probe now checks every link in /work, and the gate adds breach cases for absolute and escaping links, a writable control directory, and inode-only limit removal for task and scratch areas. Co-Authored-By: Claude Opus 5.5 --- agents/container/storage.ts | 44 ++++++++++++++++++++++- agents/policy.ts | 14 ++++---- docs/implementation/agent-isolation.md | 17 ++++++--- test/agent-container.test.ts | 34 ++++++++++++++---- test/agent-gate.test.ts | 48 +++++++++++++++++--------- 5 files changed, 122 insertions(+), 35 deletions(-) diff --git a/agents/container/storage.ts b/agents/container/storage.ts index f8b19a2..761d8cd 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -1,6 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { lstatSync } from 'node:fs'; +import { lstatSync, opendirSync, readlinkSync, realpathSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import type { TaskClone } from '../contract.ts'; import { assertTaskClone } from '../../git/clone.ts'; import { assertBuiltAgentImage } from './image.ts'; @@ -109,6 +110,46 @@ export function taskFilesystemAllocationId(filesystems: TaskFilesystems): string return allocations.get(filesystems)!.allocationId; } +const within = (base: string, path: string) => { + const rel = relative(base, path); + return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../')); +}; +/** + * Refuse a checkout whose symbolic links leave it. The seeder copies links as links, so an absolute or escaping link + * would let a path-restricted agent tool read container files outside the checkout (for example process environments + * that hold vendor credentials). Links that stay inside, including loops and not-yet-existing targets, are allowed. + */ +const assertContainedLinks = (staging: string, remaining: () => number) => { + const pending = [staging]; + let count = 0; + while (pending.length) { + remaining(); + if (++count > 200_000) throw new Error('Repository checkout exceeds the link inspection limit.'); + const path = pending.pop()!, stat = lstatSync(path); + if (stat.isSymbolicLink()) { + const target = readlinkSync(path), name = JSON.stringify(relative(staging, path)); + if (isAbsolute(target) || !within(staging, resolve(dirname(path), target))) + throw new Error(`Repository link ${name} leaves the checkout.`); + // realpathSync.native follows POSIX (each link is resolved before a later `..`), as the container kernel does; + // the JavaScript realpathSync cancels `..` textually first and would miss an escape through a chain of links. + let real: string | undefined; + try { real = realpathSync.native(path); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + if (real !== undefined && !within(staging, real)) throw new Error(`Repository link ${name} leaves the checkout.`); + continue; + } + if (!stat.isDirectory()) continue; + const directory = opendirSync(path); + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + // Git metadata is copied to its own read-only volume and is not part of the checkout. + if (path === staging && entry.name === '.git') continue; + pending.push(join(path, entry.name)); + } + } finally { directory.closeSync(); } + } +}; + /** Allocate bounded, engine-owned task filesystems and keep them mounted. */ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimits, imageId: string, timeoutMs = 60_000): TaskFilesystems { @@ -118,6 +159,7 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi const staging = assertTaskClone(clone), remaining = createDeadline(timeoutMs); if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); + assertContainedLinks(staging, remaining); const allocationId = randomUUID(); const workVolume = `codeboost-work-${randomUUID()}`, metadataVolume = `codeboost-metadata-${randomUUID()}`; const keeper = `codeboost-keeper-${randomUUID()}`, seeder = `codeboost-seeder-${randomUUID()}`; diff --git a/agents/policy.ts b/agents/policy.ts index ed1580c..5ddce5f 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -160,12 +160,14 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio + "deny sh -c ': > /work/.git/config'; deny truncate -s 0 /work/.git/config; " + 'deny rm -rf /work/.git; deny mv /work/.git /work/replaced; deny mv /work/.git /tmp/replaced; ' + 'test "$(digest)" = "$before"; git status --porcelain > /dev/null; printf metadata-unchanged', - // Repository symlinks that point outside the checkout arrive as links, never as their host targets. The search does - // not follow links, so a hostile link to `/` or a loop cannot make it walk the whole container; the link targets - // are checked separately. - 'hostile-repo': `${deny}set -eu; test -L /work/escape; test -L /work/escape-dir; ` - + 'test "$(git status --porcelain)" = ""; deny grep -rqs codeboost-host-secret /work /tmp "$HOME"; ' - + 'deny cat /work/escape; deny ls -A /work/escape-dir/; printf hostile-repo-contained', + // Every repository link in the checkout is relative and resolves inside it, and no host secret is reachable. The + // search does not follow links, so a link loop cannot make it walk the whole container. + 'hostile-repo': `${deny}set -eu; test "$(git status --porcelain)" = ""; ` + + 'find /work -path /work/.git -prune -o -type l -exec sh -c \'for link; do target=$(readlink "$link"); ' + + 'case "$target" in /*) echo "isolation breach: absolute link $link" >&2; exit 1;; esac; ' + + 'case "$(realpath -m "$link")" in /work|/work/*) ;; ' + + '*) echo "isolation breach: link leaves the checkout $link" >&2; exit 1;; esac; done\' sh {} +; ' + + 'deny grep -rqs codeboost-host-secret /work /tmp "$HOME"; printf hostile-repo-contained', }; return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]); } diff --git a/docs/implementation/agent-isolation.md b/docs/implementation/agent-isolation.md index 847b8bd..4573116 100644 --- a/docs/implementation/agent-isolation.md +++ b/docs/implementation/agent-isolation.md @@ -34,15 +34,19 @@ Each row is a T9 requirement for the Docker suite. The suite fails if any row fa | Hard links and alias writes from `.git/config` and objects fail, and metadata stays unchanged | `agent-container`: metadata alias probe in planning, review and execute, with a digest of `.git` before and after | | Mountpoint replacement fails | `agent-container`: metadata and metadata alias probes (`mv` and `rm -rf` of `.git`) | | Both vendor startup probes read the schema and return bounded valid output through their documented channel | `agent-supervisor` live probes: Codex through its output file, Claude through its stdout envelope (credentials required) | -| Hostile input stays inside the boundary | `agent-container`: repository symlinks to host files, oversized repositories fail closed; `agent-policy`: option-like prompts; `agent-proxy`: hostile CONNECT traffic; `agent-supervisor`: hostile output | +| Hostile input stays inside the boundary | `agent-container`: repositories with links that leave the checkout are refused, links inside the checkout still work, oversized repositories fail closed; `agent-policy`: option-like prompts; `agent-proxy`: hostile CONNECT traffic; `agent-supervisor`: hostile output | ## Why the gate can fail A test that cannot fail proves nothing. `agent-gate` runs each negative probe from production in a container that is missing one protection. It then checks that the -probe reports that exact breach. The cases are writable Git metadata, a writable -worktree in a read-only phase, an unbounded task filesystem, unbounded scratch, and -secret content in the worktree. +probe reports that exact breach. The cases include writable Git metadata, a writable +worktree in each read-only phase, task and scratch areas without a byte or an inode +limit, the Codex-only scratch areas, a writable control directory, and repository +links or secret content in the worktree. + +A probe also discards the output of any forbidden command it tries. So a breach that +succeeds, such as reading a file through a link, cannot copy data into the output. Probe scripts must use the `deny` helper for actions that must fail. Do not write `! command` in a probe: `set -e` ignores a negated command, so the probe would @@ -55,7 +59,10 @@ Use only these entry points to run an agent: 1. `createTaskClone` creates a committed, standalone staging clone. 2. `prepareTaskFilesystems` copies that clone into bounded task storage. Call - `removeTaskFilesystems` when the task ends. + `removeTaskFilesystems` when the task ends. It refuses a repository that has a + symbolic link with an absolute target or a target outside the checkout, before it + creates any storage. Report this to the user as a repository the agent cannot run + on; do not retry it. 3. `captureInvocation` freezes the request. Capture each attempt ID once. A new attempt needs a new attempt ID. 4. `startCodexInvocation` or `startClaudeInvocation` runs the agent and returns a diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index bcbf064..ec1463c 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -157,17 +157,39 @@ describe('real Docker agent isolation', () => { expect(runContainer(claude, 60_000, { CLAUDE_CODE_OAUTH_TOKEN: placeholder })).toBe('scratch-bounded'); }, 120_000); - it('seeds repository symlinks that point at host files as links, without their targets', () => { - const data = fixture({ hostile: (source, root) => { + it.each([ + ['an absolute link to a host file', (source: string, root: string) => { writeFileSync(join(root, 'host-only.txt'), 'codeboost-host-secret\n'); symlinkSync(join(root, 'host-only.txt'), join(source, 'escape')); - symlinkSync(root, join(source, 'escape-dir')); - // Links a traversal must not follow: the container root and a self-reference. - symlinkSync('/', join(source, 'root-link')); + }], + ['an absolute link to the filesystem root', (source: string) => symlinkSync('/', join(source, 'root-link'))], + ['a relative link that climbs out of the checkout', (source: string) => { + mkdirSync(join(source, 'nested')); symlinkSync('../../..', join(source, 'nested', 'up')); + }], + ['a chain of in-checkout links that ends outside it', (source: string) => { + mkdirSync(join(source, 'deep')); mkdirSync(join(source, 'deep', 'er')); + symlinkSync('../..', join(source, 'deep', 'er', 'top')); + symlinkSync('deep/er/top/..', join(source, 'chained')); + }], + ] as const)('refuses to seed a repository with %s, before any storage exists', (_label, hostile) => { + const owned = () => [docker('volume', 'ls', '--quiet', '--filter', 'label=io.codeboost.allocation'), + docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.allocation')].join('\n').split('\n').filter(Boolean); + const before = new Set(owned()); + expect(() => fixture({ hostile })).toThrow('leaves the checkout'); + expect(owned().filter(id => !before.has(id))).toEqual([]); + }, 60_000); + + it('seeds links that stay inside the checkout, including loops and not-yet-existing targets', () => { + const data = fixture({ hostile: source => { + mkdirSync(join(source, 'docs')); + writeFileSync(join(source, 'docs', 'guide.md'), 'guide\n'); + symlinkSync('docs/guide.md', join(source, 'readme-link')); + symlinkSync('../docs', join(source, 'docs', 'self')); symlinkSync('.', join(source, 'loop')); + symlinkSync('later.txt', join(source, 'future')); } }); const started = performance.now(); - expect(runContainer(profile(data, 'execute', 'hostile-repo'))).toBe('hostile-repo-contained'); + expect(runContainer(profile(data, 'planning', 'hostile-repo'))).toBe('hostile-repo-contained'); expect(performance.now() - started).toBeLessThan(30_000); }, 60_000); diff --git a/test/agent-gate.test.ts b/test/agent-gate.test.ts index 6bcac87..d97be44 100644 --- a/test/agent-gate.test.ts +++ b/test/agent-gate.test.ts @@ -61,6 +61,25 @@ describe('isolation gate detects breaches', () => { expect(result.stderr).toContain(breach); }, 180_000); + it('fails the scratch probe when the deferred-output control directory is writable', () => { + const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch, + ...tmpfs('/run/codeboost-control', 'size=64k,nr_inodes=16')], probeScript('execute', 'scratch-capacity')); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stderr).toContain('isolation breach: touch /run/codeboost-control/forged'); + }, 120_000); + + // Byte limits alone are not enough: with bytes bounded but inodes unlimited, the file-count loops must fail. + it.each([ + ['scratch', 'scratch-capacity', [...bounded, ...writableMetadata, ...tmpfs('/tmp', 'size=32m,nr_inodes=1000000')], + 'scratch-bounded'], + ['task', 'capacity', [...tmpfs('/work', 'size=16m,nr_inodes=1000000'), ...writableMetadata, ...boundedScratch], + 'bounded'], + ] as const)('fails the %s probe when only its inode limit is missing', (_label, probe, mounts, marker) => { + const result = runBroken(mounts, probeScript('execute', probe)); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stdout).not.toContain(marker); + }, 180_000); + it.each(['planning', 'questions', 'review'] as const)('fails the phase probe for a writable worktree in %s', phase => { const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], probeScript(phase, 'phase-worktree')); expect(result.status, result.stderr).not.toBe(0); @@ -92,25 +111,20 @@ describe('isolation gate detects breaches', () => { expect(result.stdout).not.toContain('scratch-bounded'); }, 120_000); - it('fails the hostile-repository probe when a repository link resolves inside the container', () => { - // A link that resolves is readable through the checkout, whatever it contains. - const linked = 'ln -s /etc/hostname /work/escape && ln -s /nonexistent /work/escape-dir && git -C /work add -A ' - + '&& git -C /work -c user.name=gate -c user.email=gate@example.com commit -q -m links && '; + const commit = (setup: string) => `${setup} && git -C /work add -A ` + + '&& git -C /work -c user.name=gate -c user.email=gate@example.com commit -q -m hostile && '; + it.each([ + ['an absolute repository link', 'ln -s /etc/hostname /work/escape', 'isolation breach: absolute link /work/escape'], + ['a relative repository link that leaves the checkout', 'mkdir /work/nested && ln -s ../../etc /work/nested/up', + 'isolation breach: link leaves the checkout /work/nested/up'], + ['secret content in the checkout', 'printf codeboost-host-secret > /work/leak', + 'isolation breach: grep -rqs codeboost-host-secret'], + ] as const)('fails the hostile-repository probe for %s', (_label, setup, breach) => { const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], - linked + probeScript('execute', 'hostile-repo')); + commit(setup) + probeScript('execute', 'hostile-repo')); expect(result.status, result.stderr).not.toBe(0); - expect(result.stderr).toContain('isolation breach: cat /work/escape'); - // The probe reports only its fixed diagnostic; the readable target's contents never reach the output. + expect(result.stderr).toContain(breach); + // The probe reports only its fixed diagnostic; no linked or leaked content reaches the output. expect(result.stdout).toBe(''); }, 120_000); - - it('fails the hostile-repository probe when secret content reaches the task filesystem', () => { - // Stand-in for a seeder that followed a symlink: the secret text is committed into /work behind the link names. - const leaked = 'printf codeboost-host-secret > /work/leak && ln -s /work/leak /work/escape && ln -s /tmp /work/escape-dir ' - + '&& git -C /work add -A && git -C /work -c user.name=gate -c user.email=gate@example.com commit -q -m leak && '; - const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], - leaked + probeScript('execute', 'hostile-repo')); - expect(result.status, result.stderr).not.toBe(0); - expect(result.stderr).toContain('isolation breach: grep -rqs codeboost-host-secret'); - }, 120_000); }); From f143be517c7e3df8c104e663ee056bbb5b613f8f Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 16:37:21 -0700 Subject: [PATCH 07/11] Allow contained link cycles and resolve link targets explicitly - realpathSync.native reports ELOOP for an in-checkout cycle such as a -> b, b -> a, and seeding rethrew it, refusing a repository whose links all stay inside. A cycle never resolves, so treat it like a missing target; direct escapes are still refused lexically. - The hostile-repo probe resolves each link's target from the link's directory rather than canonicalizing the link path, and treats an unresolvable cycle as contained. Co-Authored-By: Claude Opus 5.5 --- agents/container/storage.ts | 4 +++- agents/policy.ts | 4 +++- test/agent-container.test.ts | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/agents/container/storage.ts b/agents/container/storage.ts index 761d8cd..d83ec70 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -134,7 +134,9 @@ const assertContainedLinks = (staging: string, remaining: () => number) => { // the JavaScript realpathSync cancels `..` textually first and would miss an escape through a chain of links. let real: string | undefined; try { real = realpathSync.native(path); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + // A missing target or a cycle of links never resolves, so it cannot reach anything; direct escapes were already + // refused by the lexical check above. + catch (error) { if (!['ENOENT', 'ELOOP'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; } if (real !== undefined && !within(staging, real)) throw new Error(`Repository link ${name} leaves the checkout.`); continue; } diff --git a/agents/policy.ts b/agents/policy.ts index 5ddce5f..626b198 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -165,7 +165,9 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio 'hostile-repo': `${deny}set -eu; test "$(git status --porcelain)" = ""; ` + 'find /work -path /work/.git -prune -o -type l -exec sh -c \'for link; do target=$(readlink "$link"); ' + 'case "$target" in /*) echo "isolation breach: absolute link $link" >&2; exit 1;; esac; ' - + 'case "$(realpath -m "$link")" in /work|/work/*) ;; ' + // Resolve the target from the link's directory; a cycle never resolves and cannot reach anything. + + 'resolved=$(realpath -m "$(dirname "$link")/$target" 2>/dev/null) || continue; ' + + 'case "$resolved" in /work|/work/*) ;; ' + '*) echo "isolation breach: link leaves the checkout $link" >&2; exit 1;; esac; done\' sh {} +; ' + 'deny grep -rqs codeboost-host-secret /work /tmp "$HOME"; printf hostile-repo-contained', }; diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index ec1463c..d52a7ae 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -186,6 +186,8 @@ describe('real Docker agent isolation', () => { symlinkSync('docs/guide.md', join(source, 'readme-link')); symlinkSync('../docs', join(source, 'docs', 'self')); symlinkSync('.', join(source, 'loop')); + symlinkSync('cycle-b', join(source, 'cycle-a')); + symlinkSync('cycle-a', join(source, 'cycle-b')); symlinkSync('later.txt', join(source, 'future')); } }); const started = performance.now(); From 816628e9ea51058d219a9e5a79d5509d8cc82e8f Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 16:51:13 -0700 Subject: [PATCH 08/11] Prove each metadata-alias operation is detected on its own The metadata-alias probe exits at its first breach, so the gate only proved detection of the first succeeding operation. For each documented operation (hard links, alias writes, config overwrite and truncation, removal and both moves), the gate now checks the production script denies it and that its deny line reports it when the operation succeeds against plain writable metadata. Co-Authored-By: Claude Opus 5.5 --- test/agent-gate.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/agent-gate.test.ts b/test/agent-gate.test.ts index d97be44..067dfa7 100644 --- a/test/agent-gate.test.ts +++ b/test/agent-gate.test.ts @@ -80,6 +80,31 @@ describe('isolation gate detects breaches', () => { expect(result.stdout).not.toContain(marker); }, 180_000); + // The metadata-alias probe exits at its first breach, so each documented operation is also proven on its own: the + // production script must deny it, and that deny line must report it when the operation succeeds. Metadata here is a + // plain writable .git on the task filesystem, so links, aliases, truncation, removal and moves all succeed. + it.each([ + ['ln /work/.git/config "$target/config-link"', 'isolation breach: ln /work/.git/config /work/config-link'], + ['ln "$object" "$target/object-link"', '/work/object-link'], + ["sh -c 'printf x >> /tmp/config-alias'", 'isolation breach: sh -c printf x >> /tmp/config-alias'], + ["sh -c 'printf x >> /tmp/object-alias'", 'isolation breach: sh -c printf x >> /tmp/object-alias'], + ["sh -c ': > /work/.git/config'", 'isolation breach: sh -c : > /work/.git/config'], + ['truncate -s 0 /work/.git/config', 'isolation breach: truncate -s 0 /work/.git/config'], + ['rm -rf /work/.git', 'isolation breach: rm -rf /work/.git'], + ['mv /work/.git /work/replaced', 'isolation breach: mv /work/.git /work/replaced'], + ['mv /work/.git /tmp/replaced', 'isolation breach: mv /work/.git /tmp/replaced'], + ] as const)('reports the metadata operation %s on its own when it succeeds', (operation, breach) => { + const script = probeScript('execute', 'metadata-alias'); + expect(script).toContain(`deny ${operation}`); + const denyHelper = script.slice(0, script.indexOf('set -eu; ')); + const prelude = 'object=$(find /work/.git/objects -type f | head -n 1); chmod -R u+w /work/.git/objects; target=/work; ' + + 'ln -s /work/.git/config /tmp/config-alias; ln -s "$object" /tmp/object-alias; '; + const result = runBroken([...bounded, ...boundedScratch], `${denyHelper}set -eu; ${prelude}deny ${operation}`); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stderr).toContain(breach); + expect(result.stderr).toContain('isolation breach:'); + }, 120_000); + it.each(['planning', 'questions', 'review'] as const)('fails the phase probe for a writable worktree in %s', phase => { const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], probeScript(phase, 'phase-worktree')); expect(result.status, result.stderr).not.toBe(0); From 48347d69345ff6735304c007f5674649ee4828b7 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 17:08:14 -0700 Subject: [PATCH 09/11] Refuse links in cloned Git metadata and bound the link scan per entry - The link scan skipped .git, but the seeder copies it and it is mounted at /work/.git, where the read-only mount stops writes but not reads through a link. Git never needs links in its own metadata, so any link there is now refused before allocation. - Check the deadline and the entry limit before each directory entry is queued, as the clone object audit does, so one huge directory cannot defer either bound. - Add Codex gate cases that remove only the inode limit of CODEX_HOME or the output directory. Co-Authored-By: Claude Opus 5.5 --- agents/container/storage.ts | 25 ++++++++++++++++--------- test/agent-container.test.ts | 15 +++++++++++++++ test/agent-gate.test.ts | 11 +++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/agents/container/storage.ts b/agents/container/storage.ts index d83ec70..9818ffa 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -114,20 +114,25 @@ const within = (base: string, path: string) => { const rel = relative(base, path); return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../')); }; +const LINK_INSPECTION_LIMIT = 200_000; /** - * Refuse a checkout whose symbolic links leave it. The seeder copies links as links, so an absolute or escaping link - * would let a path-restricted agent tool read container files outside the checkout (for example process environments - * that hold vendor credentials). Links that stay inside, including loops and not-yet-existing targets, are allowed. + * Refuse a checkout whose symbolic links leave it, or whose Git metadata contains any link. The seeder copies links as + * links, so an absolute or escaping link would let a path-restricted agent tool read container files outside the + * checkout (for example process environments that hold vendor credentials). Worktree links that stay inside, including + * loops and not-yet-existing targets, are allowed. */ const assertContainedLinks = (staging: string, remaining: () => number) => { - const pending = [staging]; + const metadata = join(staging, '.git'), pending = [staging]; let count = 0; while (pending.length) { remaining(); - if (++count > 200_000) throw new Error('Repository checkout exceeds the link inspection limit.'); + count++; const path = pending.pop()!, stat = lstatSync(path); if (stat.isSymbolicLink()) { - const target = readlinkSync(path), name = JSON.stringify(relative(staging, path)); + const name = JSON.stringify(relative(staging, path)); + // Git never needs links in its own metadata, which is mounted at /work/.git; refuse any, wherever it points. + if (within(metadata, path)) throw new Error(`Repository Git metadata contains a link ${name}.`); + const target = readlinkSync(path); if (isAbsolute(target) || !within(staging, resolve(dirname(path), target))) throw new Error(`Repository link ${name} leaves the checkout.`); // realpathSync.native follows POSIX (each link is resolved before a later `..`), as the container kernel does; @@ -141,11 +146,13 @@ const assertContainedLinks = (staging: string, remaining: () => number) => { continue; } if (!stat.isDirectory()) continue; - const directory = opendirSync(path); + const directory = opendirSync(path, { bufferSize: 1 }); try { for (let entry = directory.readSync(); entry; entry = directory.readSync()) { - // Git metadata is copied to its own read-only volume and is not part of the checkout. - if (path === staging && entry.name === '.git') continue; + // Bound time and memory per entry, so one huge directory cannot defer the deadline or the entry limit. + remaining(); + if (count + pending.length >= LINK_INSPECTION_LIMIT) + throw new Error('Repository checkout exceeds the link inspection limit.'); pending.push(join(path, entry.name)); } } finally { directory.closeSync(); } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index d52a7ae..7cfb4c3 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -179,6 +179,21 @@ describe('real Docker agent isolation', () => { expect(owned().filter(id => !before.has(id))).toEqual([]); }, 60_000); + it('refuses to seed a clone whose Git metadata contains a link, before any storage exists', () => { + const data = fixture(); + const clone = createTaskClone({ source: data.source, parent: join(data.root, 'staging'), taskId: 'task-git-link', + head: git(data.source, 'rev-parse', 'HEAD') }); + // /work/.git is mounted read-only, which stops writes but not reads through a link. + symlinkSync('/run/codeboost-auth/codex/auth.json', join(clone.directory, '.git', 'credential')); + const owned = () => [docker('volume', 'ls', '--quiet', '--filter', 'label=io.codeboost.allocation'), + docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.allocation')].join('\n').split('\n').filter(Boolean); + const before = new Set(owned()); + expect(() => prepareTaskFilesystems(clone, { + workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, + }, imageId)).toThrow('Git metadata contains a link'); + expect(owned().filter(id => !before.has(id))).toEqual([]); + }, 60_000); + it('seeds links that stay inside the checkout, including loops and not-yet-existing targets', () => { const data = fixture({ hostile: source => { mkdirSync(join(source, 'docs')); diff --git a/test/agent-gate.test.ts b/test/agent-gate.test.ts index 067dfa7..805b6be 100644 --- a/test/agent-gate.test.ts +++ b/test/agent-gate.test.ts @@ -128,6 +128,17 @@ describe('isolation gate detects breaches', () => { expect(result.stderr).toContain(breach); }, 180_000); + // Bytes stay bounded so each fill stops at dd; only the file-count limit of one Codex area is missing. + it.each([ + ['CODEX_HOME', [...codexHome('size=4m,nr_inodes=1000000'), ...codexOutput('size=20m,nr_inodes=64')]], + ['Codex output directory', [...codexHome('size=4m,nr_inodes=256'), ...codexOutput('size=20m,nr_inodes=1000000')]], + ] as const)('fails the scratch probe when %s has no inode limit', (_label, codexMounts) => { + const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch, ...codexMounts], + probeScript('execute', 'scratch-capacity'), codexEnvironment); + expect(result.status, result.stderr).not.toBe(0); + expect(result.stdout).not.toContain('scratch-bounded'); + }, 180_000); + it('fails the scratch probe for a Codex container whose Codex scratch areas are missing', () => { // Missing scratch areas must fail the probe, not skip their checks and report the container as bounded. const result = runBroken([...bounded, ...writableMetadata, ...boundedScratch], From 894468dbcb8468fa43d1e34a02284c36ef58860f Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 17:23:42 -0700 Subject: [PATCH 10/11] Resolve repository links as the container kernel does Link containment treated a link whose target was missing on the host as harmless, so a chain such as deep/er/top -> ../.. plus chained -> deep/er/top/../run/codeboost-auth/codex/auth.json passed: the host lacks that path, but in the container /work/.. is / and the credential mount exists. Replace the host realpath check with a resolver that follows each existing link component by component from the link's directory, applies missing components textually, and requires the path to stay inside the checkout after every step. More than 40 hops is a cycle, which cannot resolve and is contained. Co-Authored-By: Claude Opus 5.5 --- agents/container/storage.ts | 45 +++++++++++++++++++++++++----------- test/agent-container.test.ts | 6 +++++ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/agents/container/storage.ts b/agents/container/storage.ts index 9818ffa..b82a5de 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -1,7 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { lstatSync, opendirSync, readlinkSync, realpathSync } from 'node:fs'; -import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { lstatSync, opendirSync, readlinkSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative } from 'node:path'; import type { TaskClone } from '../contract.ts'; import { assertTaskClone } from '../../git/clone.ts'; import { assertBuiltAgentImage } from './image.ts'; @@ -115,6 +115,35 @@ const within = (base: string, path: string) => { return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../')); }; const LINK_INSPECTION_LIMIT = 200_000; +// The Linux kernel gives up after 40 link hops (ELOOP); a cycle never resolves, so it cannot reach anything. +const MAXIMUM_LINK_HOPS = 40; +/** + * Resolve a link as the container kernel will, with the checkout standing for /work. Each existing link along the way + * is followed, `..` is applied to the resolved path, and the path must stay inside the checkout after every step. + * Components that do not exist here are applied textually: /work mirrors the checkout, so they are missing there too, + * and a target the host lacks (such as a container mount under /run) cannot hide an escape. + */ +const linkStaysInside = (staging: string, link: string) => { + let current = dirname(link), hops = 0, exists = true; + const components = readlinkSync(link).split('/'); + if (components[0] === '') return false; + while (components.length) { + const component = components.shift()!; + if (component === '' || component === '.') continue; + current = component === '..' ? dirname(current) : join(current, component); + if (!within(staging, current)) return false; + if (!exists || component === '..') continue; + const stat = lstatSync(current, { throwIfNoEntry: false }); + if (!stat) { exists = false; continue; } + if (!stat.isSymbolicLink()) continue; + if (++hops > MAXIMUM_LINK_HOPS) return true; + const target = readlinkSync(current); + if (target.startsWith('/')) return false; + components.unshift(...target.split('/')); + current = dirname(current); + } + return true; +}; /** * Refuse a checkout whose symbolic links leave it, or whose Git metadata contains any link. The seeder copies links as * links, so an absolute or escaping link would let a path-restricted agent tool read container files outside the @@ -132,17 +161,7 @@ const assertContainedLinks = (staging: string, remaining: () => number) => { const name = JSON.stringify(relative(staging, path)); // Git never needs links in its own metadata, which is mounted at /work/.git; refuse any, wherever it points. if (within(metadata, path)) throw new Error(`Repository Git metadata contains a link ${name}.`); - const target = readlinkSync(path); - if (isAbsolute(target) || !within(staging, resolve(dirname(path), target))) - throw new Error(`Repository link ${name} leaves the checkout.`); - // realpathSync.native follows POSIX (each link is resolved before a later `..`), as the container kernel does; - // the JavaScript realpathSync cancels `..` textually first and would miss an escape through a chain of links. - let real: string | undefined; - try { real = realpathSync.native(path); } - // A missing target or a cycle of links never resolves, so it cannot reach anything; direct escapes were already - // refused by the lexical check above. - catch (error) { if (!['ENOENT', 'ELOOP'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; } - if (real !== undefined && !within(staging, real)) throw new Error(`Repository link ${name} leaves the checkout.`); + if (!linkStaysInside(staging, path)) throw new Error(`Repository link ${name} leaves the checkout.`); continue; } if (!stat.isDirectory()) continue; diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 7cfb4c3..b79e1e7 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -171,6 +171,12 @@ describe('real Docker agent isolation', () => { symlinkSync('../..', join(source, 'deep', 'er', 'top')); symlinkSync('deep/er/top/..', join(source, 'chained')); }], + ['a chain that leaves through a target this host lacks', (source: string) => { + // On the host the final path is missing, but in the container /work/.. is / and the credential mount exists. + mkdirSync(join(source, 'deep')); mkdirSync(join(source, 'deep', 'er')); + symlinkSync('../..', join(source, 'deep', 'er', 'top')); + symlinkSync('deep/er/top/../run/codeboost-auth/codex/auth.json', join(source, 'chained')); + }], ] as const)('refuses to seed a repository with %s, before any storage exists', (_label, hostile) => { const owned = () => [docker('volume', 'ls', '--quiet', '--filter', 'label=io.codeboost.allocation'), docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.allocation')].join('\n').split('\n').filter(Boolean); From dba3ecc2ff366a649647286acd4b52ae76b7fd1e Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 17:33:24 -0700 Subject: [PATCH 11/11] Compare live vendor probe values without trimming The live probes trimmed responses, so surrounding whitespace passed. They now accept the exact value, optionally followed by the single trailing newline a CLI adds; the Codex container probe requires the value as an exact, untrimmed output line. Co-Authored-By: Claude Opus 5.5 --- test/agent-container.test.ts | 4 ++-- test/agent-supervisor.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index b79e1e7..83b5140 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -643,7 +643,7 @@ describe('real Docker agent isolation', () => { // The production launch path: create, validate, start and remove. Raw stdout can carry more than the final // message, so the value must appear as a complete line; the adapter probe checks the exact file channel. const output = runContainer(authProfile, 5 * 60_000); - expect(output.split('\n').map(line => line.trim())).toContain('codeboost-schema-marker'); + expect(output.split(/\r?\n/)).toContain('codeboost-schema-marker'); }, 6 * 60_000); it('runs the authenticated Claude startup path with only its OAuth token', () => { @@ -656,7 +656,7 @@ describe('real Docker agent isolation', () => { const output = runContainer(authProfile, 5 * 60_000, { CLAUDE_CODE_OAUTH_TOKEN: token }); const envelope = JSON.parse(output) as { result?: string; is_error?: boolean }; expect(envelope.is_error).not.toBe(true); - expect(envelope.result?.trim()).toBe('codeboost-schema-marker'); + expect(envelope.result?.replace(/\r?\n$/, '')).toBe('codeboost-schema-marker'); }, 6 * 60_000); } }); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 45d2115..09f50bf 100644 --- a/test/agent-supervisor.test.ts +++ b/test/agent-supervisor.test.ts @@ -375,8 +375,9 @@ describe('container invocation supervisor', () => { if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { const schemaPrompt = 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field, ' + 'without quotes or Markdown formatting.'; - // Exact value only: the prompt forbids quotes and formatting, so anything around the value fails the probe. - const schemaValue = (output: string) => output.trim(); + // Exact value only, allowing just the single trailing newline a CLI adds; any other surrounding whitespace or + // formatting fails the probe. + const schemaValue = (output: string) => output.replace(/\r?\n$/, ''); it('runs the production Codex adapter and collects its bounded output file', async () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE;