Skip to content

Commit d181440

Browse files
committed
Merge main (D5) into F1 contract branch
2 parents c230853 + af8f3c2 commit d181440

8 files changed

Lines changed: 484 additions & 23 deletions

File tree

‎.github/workflows/agent-isolation.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,4 @@ jobs:
2929
- run: npm ci --ignore-scripts
3030
- run: npm run typecheck
3131
# The Docker suites share one image tag and daemon, so run test files one at a time.
32-
- 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
32+
- 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

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ jobs:
1818
- run: npm run typecheck
1919
# The Docker agent suites run one file at a time in the Agent isolation workflow; running them here
2020
# would put them in parallel against the same image tag and daemon.
21-
- 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
21+
- 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
2222
- run: npx playwright install --with-deps chromium
2323
- run: npm run test:browser

‎agents/container/storage.ts‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFileSync, spawnSync } from 'node:child_process';
22
import { randomUUID } from 'node:crypto';
3-
import { lstatSync } from 'node:fs';
3+
import { lstatSync, opendirSync, readlinkSync } from 'node:fs';
4+
import { dirname, isAbsolute, join, relative } from 'node:path';
45
import type { TaskClone } from '../contract.ts';
56
import { assertTaskClone } from '../../git/clone.ts';
67
import { assertBuiltAgentImage } from './image.ts';
@@ -109,6 +110,74 @@ export function taskFilesystemAllocationId(filesystems: TaskFilesystems): string
109110
return allocations.get(filesystems)!.allocationId;
110111
}
111112

113+
const within = (base: string, path: string) => {
114+
const rel = relative(base, path);
115+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../'));
116+
};
117+
const LINK_INSPECTION_LIMIT = 200_000;
118+
// The Linux kernel gives up after 40 link hops (ELOOP); a cycle never resolves, so it cannot reach anything.
119+
const MAXIMUM_LINK_HOPS = 40;
120+
/**
121+
* Resolve a link as the container kernel will, with the checkout standing for /work. Each existing link along the way
122+
* is followed, `..` is applied to the resolved path, and the path must stay inside the checkout after every step.
123+
* Components that do not exist here are applied textually: /work mirrors the checkout, so they are missing there too,
124+
* and a target the host lacks (such as a container mount under /run) cannot hide an escape.
125+
*/
126+
const linkStaysInside = (staging: string, link: string) => {
127+
let current = dirname(link), hops = 0, exists = true;
128+
const components = readlinkSync(link).split('/');
129+
if (components[0] === '') return false;
130+
while (components.length) {
131+
const component = components.shift()!;
132+
if (component === '' || component === '.') continue;
133+
current = component === '..' ? dirname(current) : join(current, component);
134+
if (!within(staging, current)) return false;
135+
if (!exists || component === '..') continue;
136+
const stat = lstatSync(current, { throwIfNoEntry: false });
137+
if (!stat) { exists = false; continue; }
138+
if (!stat.isSymbolicLink()) continue;
139+
if (++hops > MAXIMUM_LINK_HOPS) return true;
140+
const target = readlinkSync(current);
141+
if (target.startsWith('/')) return false;
142+
components.unshift(...target.split('/'));
143+
current = dirname(current);
144+
}
145+
return true;
146+
};
147+
/**
148+
* Refuse a checkout whose symbolic links leave it, or whose Git metadata contains any link. The seeder copies links as
149+
* links, so an absolute or escaping link would let a path-restricted agent tool read container files outside the
150+
* checkout (for example process environments that hold vendor credentials). Worktree links that stay inside, including
151+
* loops and not-yet-existing targets, are allowed.
152+
*/
153+
const assertContainedLinks = (staging: string, remaining: () => number) => {
154+
const metadata = join(staging, '.git'), pending = [staging];
155+
let count = 0;
156+
while (pending.length) {
157+
remaining();
158+
count++;
159+
const path = pending.pop()!, stat = lstatSync(path);
160+
if (stat.isSymbolicLink()) {
161+
const name = JSON.stringify(relative(staging, path));
162+
// Git never needs links in its own metadata, which is mounted at /work/.git; refuse any, wherever it points.
163+
if (within(metadata, path)) throw new Error(`Repository Git metadata contains a link ${name}.`);
164+
if (!linkStaysInside(staging, path)) throw new Error(`Repository link ${name} leaves the checkout.`);
165+
continue;
166+
}
167+
if (!stat.isDirectory()) continue;
168+
const directory = opendirSync(path, { bufferSize: 1 });
169+
try {
170+
for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
171+
// Bound time and memory per entry, so one huge directory cannot defer the deadline or the entry limit.
172+
remaining();
173+
if (count + pending.length >= LINK_INSPECTION_LIMIT)
174+
throw new Error('Repository checkout exceeds the link inspection limit.');
175+
pending.push(join(path, entry.name));
176+
}
177+
} finally { directory.closeSync(); }
178+
}
179+
};
180+
112181
/** Allocate bounded, engine-owned task filesystems and keep them mounted. */
113182
export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimits,
114183
imageId: string, timeoutMs = 60_000): TaskFilesystems {
@@ -118,6 +187,7 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi
118187
const staging = assertTaskClone(clone), remaining = createDeadline(timeoutMs);
119188
if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.');
120189
if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.');
190+
assertContainedLinks(staging, remaining);
121191
const allocationId = randomUUID();
122192
const workVolume = `codeboost-work-${randomUUID()}`, metadataVolume = `codeboost-metadata-${randomUUID()}`;
123193
const keeper = `codeboost-keeper-${randomUUID()}`, seeder = `codeboost-seeder-${randomUUID()}`;

‎agents/policy.ts‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,20 @@ export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' |
9393
| 'infinite-stdout' | 'infinite-stderr' | 'infinite-mixed' | 'ignore-term' | 'symlink-output'
9494
| 'oversized-output' | 'fifo-output' | 'invalid-utf8-output' | 'invalid-utf8-stderr' | 'truncated-utf8-stderr'
9595
| 'replace-output-directory'
96-
| 'nonzero-output' | 'duplicate-protocol' | 'newline-free-deferred-output';
96+
| 'nonzero-output' | 'duplicate-protocol' | 'newline-free-deferred-output' | 'scratch-capacity' | 'metadata-alias'
97+
| 'hostile-repo';
98+
99+
// `set -e` ignores a failing `! command`, so a negated check could never fail a probe. `deny` exits instead when a
100+
// forbidden action succeeds, and names the breach. Both streams of the attempted command are discarded, so a breach
101+
// that succeeds (such as reading a host file) cannot copy its data into the invocation output.
102+
const deny = 'deny() { if "$@" >/dev/null 2>&1; then echo "isolation breach: $*" >&2; exit 1; fi; }; ';
103+
// Fill a scratch directory past its byte and inode limits. Each fill must stop early, and must have written first, so
104+
// an unwritable or missing directory fails the probe instead of passing it vacuously.
105+
const scratchBounded = (directory: string, megabytes: number, files: number) =>
106+
`deny dd if=/dev/zero of="${directory}/overflow" bs=1M count=${megabytes}; test -s "${directory}/overflow"; `
107+
+ `rm -f "${directory}/overflow"; mkdir "${directory}/many"; i=0; `
108+
+ `while touch "${directory}/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt ${files}; done; `
109+
+ `test "$i" -gt 0; test "$i" -lt ${files}; rm -rf "${directory}/many"; `;
97110

98111
/** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */
99112
export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand {
@@ -102,17 +115,17 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio
102115
const scripts: Record<Exclude<IsolationProbe, 'noop'>, string> = {
103116
'phase-worktree': policy.worktree === 'read-write'
104117
? `set -eu; printf ${phase} > /work/${phase}.txt; test -f /work/${phase}.txt`
105-
: `set -eu; ! touch /work/${phase}.txt 2>/dev/null; test ! -e /work/${phase}.txt`,
106-
'read-only-isolation': 'set -eu; test "$(id -u)" = 10001; test "$(git status --porcelain)" = ""; '
107-
+ 'test -z "${HOST_SECRET_SENTINEL:-}"; ! touch /work/forbidden; ! touch /usr/bin/forbidden; '
118+
: `${deny}set -eu; deny touch /work/${phase}.txt; test ! -e /work/${phase}.txt`,
119+
'read-only-isolation': `${deny}set -eu; test "$(id -u)" = 10001; test "$(git status --porcelain)" = ""; `
120+
+ 'test -z "${HOST_SECRET_SENTINEL:-}"; deny touch /work/forbidden; deny touch /usr/bin/forbidden; '
108121
+ 'touch /tmp/allowed "$HOME/allowed"; printf isolated',
109122
'persist-write': 'set -eu; printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first',
110123
'persist-read': 'set -eu; test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain',
111-
capacity: 'set -eu; ! dd if=/dev/zero of=/work/overflow bs=1M count=32 2>/dev/null; rm -f /work/overflow; '
124+
capacity: `${deny}set -eu; deny dd if=/dev/zero of=/work/overflow bs=1M count=32; rm -f /work/overflow; `
112125
+ 'mkdir /work/many; i=0; while touch "/work/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt 2000; done; '
113126
+ 'test "$i" -lt 2000; test "$(find /work/many -type f | wc -l)" -eq "$i"; rm -rf /work/many; printf bounded',
114-
metadata: 'set -eu; ! touch /work/.git/forbidden 2>/dev/null; ! ln /work/.git/HEAD /work/metadata-link 2>/dev/null; '
115-
+ '! mv /work/.git /work/replaced 2>/dev/null; git status --porcelain; printf metadata-safe',
127+
metadata: `${deny}set -eu; deny touch /work/.git/forbidden; deny ln /work/.git/HEAD /work/metadata-link; `
128+
+ 'deny mv /work/.git /work/replaced; git status --porcelain; printf metadata-safe',
116129
'must-not-run': 'touch /tmp/command-ran',
117130
'input-marker': 'set -eu; grep -q codeboost-schema-marker /run/codeboost-input/schema.json; '
118131
+ 'test ! -e /run/codeboost-input/extra.json',
@@ -131,6 +144,32 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio
131144
'nonzero-output': 'printf encoded-output; exit 7',
132145
'duplicate-protocol': "printf '\\036CODEBOOST_START:00000000-0000-0000-0000-000000000000\\036\\n' >&2",
133146
'newline-free-deferred-output': "printf captured > /run/codeboost-output/final.txt; printf trailing-diagnostic >&2",
147+
// Every agent-writable scratch area enforces both its byte and inode ceilings; the control area is not writable.
148+
'scratch-capacity': `${deny}set -eu; ${scratchBounded('/tmp', 64, 10000)}${scratchBounded('$HOME', 4, 1000)}`
149+
+ 'if [ "${CODEBOOST_VENDOR:-}" = codex ]; then test -n "${CODEX_HOME:-}"; '
150+
+ `${scratchBounded('$CODEX_HOME', 16, 2000)}${scratchBounded('/run/codeboost-output', 64, 1000)}fi; `
151+
+ 'if [ -d /run/codeboost-control ]; then deny touch /run/codeboost-control/forged; fi; printf scratch-bounded',
152+
// Hard links, symlink aliases, truncation and replacement all fail, and the metadata digest is unchanged.
153+
'metadata-alias': `${deny}set -eu; `
154+
+ 'digest() { (cd /work/.git && find . -type f -exec sha256sum {} + | sort | sha256sum); }; before=$(digest); '
155+
+ 'object=$(find /work/.git/objects -type f | head -n 1); test -n "$object"; '
156+
+ 'for target in /work /tmp "$HOME"; do deny ln /work/.git/config "$target/config-link"; '
157+
+ 'deny ln "$object" "$target/object-link"; done; '
158+
+ 'ln -s /work/.git/config /tmp/config-alias; ln -s "$object" /tmp/object-alias; '
159+
+ "deny sh -c 'printf x >> /tmp/config-alias'; deny sh -c 'printf x >> /tmp/object-alias'; "
160+
+ "deny sh -c ': > /work/.git/config'; deny truncate -s 0 /work/.git/config; "
161+
+ 'deny rm -rf /work/.git; deny mv /work/.git /work/replaced; deny mv /work/.git /tmp/replaced; '
162+
+ 'test "$(digest)" = "$before"; git status --porcelain > /dev/null; printf metadata-unchanged',
163+
// Every repository link in the checkout is relative and resolves inside it, and no host secret is reachable. The
164+
// search does not follow links, so a link loop cannot make it walk the whole container.
165+
'hostile-repo': `${deny}set -eu; test "$(git status --porcelain)" = ""; `
166+
+ 'find /work -path /work/.git -prune -o -type l -exec sh -c \'for link; do target=$(readlink "$link"); '
167+
+ 'case "$target" in /*) echo "isolation breach: absolute link $link" >&2; exit 1;; esac; '
168+
// Resolve the target from the link's directory; a cycle never resolves and cannot reach anything.
169+
+ 'resolved=$(realpath -m "$(dirname "$link")/$target" 2>/dev/null) || continue; '
170+
+ 'case "$resolved" in /work|/work/*) ;; '
171+
+ '*) echo "isolation breach: link leaves the checkout $link" >&2; exit 1;; esac; done\' sh {} +; '
172+
+ 'deny grep -rqs codeboost-host-secret /work /tmp "$HOME"; printf hostile-repo-contained',
134173
};
135174
return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]);
136175
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# D5 agent isolation gate
2+
3+
This page describes the combined gate for lane D. The gate is the set of real-Docker
4+
tests that must pass before lanes F and G may run agents in production. It also
5+
states what those lanes must do when they call the isolation boundary.
6+
7+
## Run the gate
8+
9+
The gate needs a running Docker daemon. Run the suites one file at a time, because
10+
they share one image tag and one daemon:
11+
12+
```bash
13+
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
14+
```
15+
16+
The `Agent isolation` workflow runs the same command. The main `CI` workflow skips
17+
the Docker suites so that they never run in parallel.
18+
19+
The live vendor probes need real credentials, so CI does not run them. To run them,
20+
set `CODEBOOST_RUN_AUTH_PROBES=1`, `CODEBOOST_CODEX_AUTH_FILE` (a Codex `auth.json`
21+
path) and `CLAUDE_CODE_OAUTH_TOKEN`. Do not put credentials in an issue, a pull
22+
request or chat.
23+
24+
## What the gate proves
25+
26+
Each row is a T9 requirement for the Docker suite. The suite fails if any row fails.
27+
28+
| Requirement | Tests |
29+
| --- | --- |
30+
| 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 |
31+
| A read-only phase cannot write `/work` | `agent-container`: phase worktree for all five phases |
32+
| Planning and questions cannot run a process | `agent-policy`: tool sets exclude the command tool, and command dispatch refuses these phases |
33+
| Task and scratch byte and inode limits hold | `agent-container`: task capacity; scratch capacity for Codex and Claude (`/tmp`, `HOME`, `CODEX_HOME`, output directory) |
34+
| 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 |
35+
| Mountpoint replacement fails | `agent-container`: metadata and metadata alias probes (`mv` and `rm -rf` of `.git`) |
36+
| 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) |
37+
| 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 |
38+
39+
## Why the gate can fail
40+
41+
A test that cannot fail proves nothing. `agent-gate` runs each negative probe from
42+
production in a container that is missing one protection. It then checks that the
43+
probe reports that exact breach. The cases include writable Git metadata, a writable
44+
worktree in each read-only phase, task and scratch areas without a byte or an inode
45+
limit, the Codex-only scratch areas, a writable control directory, and repository
46+
links or secret content in the worktree.
47+
48+
A probe also discards the output of any forbidden command it tries. So a breach that
49+
succeeds, such as reading a file through a link, cannot copy data into the output.
50+
51+
Probe scripts must use the `deny` helper for actions that must fail. Do not write
52+
`! command` in a probe: `set -e` ignores a negated command, so the probe would
53+
continue and report success even when the forbidden action worked. Before D5, the
54+
metadata, read-only isolation and capacity probes had this defect.
55+
56+
## Handoff to lanes F and G
57+
58+
Use only these entry points to run an agent:
59+
60+
1. `createTaskClone` creates a committed, standalone staging clone.
61+
2. `prepareTaskFilesystems` copies that clone into bounded task storage. Call
62+
`removeTaskFilesystems` when the task ends. It refuses a repository that has a
63+
symbolic link with an absolute target or a target outside the checkout, before it
64+
creates any storage. Report this to the user as a repository the agent cannot run
65+
on; do not retry it.
66+
3. `captureInvocation` freezes the request. Capture each attempt ID once. A new
67+
attempt needs a new attempt ID.
68+
4. `startCodexInvocation` or `startClaudeInvocation` runs the agent and returns a
69+
handle. Pass the vendor credential only as the function argument.
70+
71+
The boundary guarantees the following:
72+
73+
- The profile is immutable, and every launch revalidates it against Docker.
74+
- Tools are limited by phase. Planning and questions can only read, list and search.
75+
- Web search and MCP are off, stdin is closed, and network traffic reaches only the
76+
vendor hosts.
77+
- Output is bounded and decoded as strict UTF-8. Invalid output fails as
78+
`capture-failure`.
79+
- The invocation deadline bounds every launch. Cleanup still runs after the deadline.
80+
81+
The caller must do the following:
82+
83+
- Keep ownership until `settled` resolves. It resolves only after the container has
84+
stopped and its cleanup has finished. The supervisor retries cleanup until then.
85+
- Call `cancel` to stop an invocation. The first stop reason is kept.
86+
- Treat `stopReason` as the result of the invocation. A missing `stopReason` means
87+
the agent finished normally.
88+
89+
## Limits of this gate
90+
91+
- CI does not run the live vendor probes. Run them locally with credentials before
92+
a release that changes the image, the adapters or the prompts.
93+
- T9 as a whole is complete only when lane F runs every suite in required CI (F6).

0 commit comments

Comments
 (0)