Skip to content

Commit af8f3c2

Browse files
mchwangclaude
andauthored
D5: Add the combined real-Docker isolation gate (#50)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5881a43 commit af8f3c2

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)