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/container/storage.ts b/agents/container/storage.ts index f8b19a2..b82a5de 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 } 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'; @@ -109,6 +110,74 @@ 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('../')); +}; +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 + * 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 metadata = join(staging, '.git'), pending = [staging]; + let count = 0; + while (pending.length) { + remaining(); + count++; + const path = pending.pop()!, stat = lstatSync(path); + if (stat.isSymbolicLink()) { + 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}.`); + if (!linkStaysInside(staging, path)) throw new Error(`Repository link ${name} leaves the checkout.`); + continue; + } + if (!stat.isDirectory()) continue; + const directory = opendirSync(path, { bufferSize: 1 }); + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + // 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(); } + } +}; + /** 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 +187,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 0d1ebaf..626b198 100644 --- a/agents/policy.ts +++ b/agents/policy.ts @@ -93,7 +93,20 @@ 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. 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) => + `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 { @@ -102,17 +115,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 +144,32 @@ 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 [ "${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. + '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', + // 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; ' + // 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', }; 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..4573116 --- /dev/null +++ b/docs/implementation/agent-isolation.md @@ -0,0 +1,93 @@ +# 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`: 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 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 +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. 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 + 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..83b5140 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,90 @@ 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.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')); + }], + ['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')); + }], + ['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); + const before = new Set(owned()); + expect(() => fixture({ hostile })).toThrow('leaves the checkout'); + 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')); + 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('cycle-b', join(source, 'cycle-a')); + symlinkSync('cycle-a', join(source, 'cycle-b')); + symlinkSync('later.txt', join(source, 'future')); + } }); + const started = performance.now(); + expect(runContainer(profile(data, 'planning', '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', () => { + 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'); @@ -552,11 +638,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(/\r?\n/)).toContain('codeboost-schema-marker'); }, 6 * 60_000); it('runs the authenticated Claude startup path with only its OAuth token', () => { @@ -569,9 +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); - // 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?.replace(/\r?\n$/, '')).toBe('codeboost-schema-marker'); }, 6 * 60_000); } }); diff --git a/test/agent-gate.test.ts b/test/agent-gate.test.ts new file mode 100644 index 0000000..805b6be --- /dev/null +++ b/test/agent-gate.test.ts @@ -0,0 +1,166 @@ +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, env: readonly string[] = []) => spawnSync('docker', ['run', + '--rm', '--network=none', '--user', '10001:10001', '--env', 'HOME=/home/codeboost', ...env, '--workdir', '/work', ...mounts, + ...(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}`]; +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'], + ['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'], + ['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) => { + 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 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); + + // 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); + 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); + + // 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], + 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); + + 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], + commit(setup) + probeScript('execute', 'hostile-repo')); + expect(result.status, result.stderr).not.toBe(0); + 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); +}); diff --git a/test/agent-supervisor.test.ts b/test/agent-supervisor.test.ts index 7204045..09f50bf 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,22 @@ 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.'; + // 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; 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 +396,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); } });