Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/agent-isolation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
72 changes: 71 additions & 1 deletion agents/container/storage.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -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()}`;
Expand Down
53 changes: 46 additions & 7 deletions agents/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -102,17 +115,17 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio
const scripts: Record<Exclude<IsolationProbe, 'noop'>, 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',
Expand All @@ -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]]);
}
93 changes: 93 additions & 0 deletions docs/implementation/agent-isolation.md
Original file line number Diff line number Diff line change
@@ -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).
Loading
Loading