Skip to content

Commit f8c8d9f

Browse files
mchwangclaude
andauthored
Add vendor-only egress and phase policy (D3) (#44)
* Add vendor-only egress and phase policy * Enforce invocation-scoped agent policy * Bind adapters and proxy checks to invocation * Block agent DNS and harden proxy validation * Close remaining network lifecycle gaps * Stabilize live Claude marker probe * Port D2 regression tests to named isolation probes D3 replaced raw test argv with fixed isolation probes, so the seeding, page-rounding and namespace regressions from D2 now use the metadata and noop probes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Carry D2 seccomp pinning into the egress proxy Request and require Docker's builtin seccomp profile for the vendor egress proxy too, and port D2's new regressions to the named isolation probes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Own vendor networks on profile failure and bound network cleanup - Claim the vendor network before any fallible profile work and remove it if creation fails, aggregating cleanup errors. - Reserve cleanup time inside createVendorNetwork's deadline so failure cleanup cannot overrun the caller's timeoutMs. - Compare the live Claude marker exactly, tolerating only one wrapping pair of backticks or quotes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Carry D2 deadline, runtime and capture rules into D3 - Clamp runContainer to the captured invocation deadline, as the other launch paths do, and pin and require the runc runtime for the egress proxy. - Port D2's new regressions to the named isolation probes, and give each captured test request its own attempt identity now that an attempt can only be captured once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Require captured invocations for policies and serialize Docker suites - createPhasePolicy and createVendorNetwork now reject any invocation not produced by captureInvocation, so a forged request cannot define tools, an allowlist or a network. - Run the agent isolation CI suites with --no-file-parallelism, since the Docker suites share one image tag and daemon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bound network checks by the invocation deadline and reconcile late creates - createContainerProfile bounds its vendor network revalidation by the invocation's remaining time, and createVendorNetwork clamps its budget to the invocation deadline; both refuse once it has passed. - Track whether the network create or proxy run client was killed, and give that object a settle window inside the caller's cleanup reserve, removing it if it lands late. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * End CLI option parsing before agent prompts - Place the prompt after `--` for both Codex and Claude, so a prompt beginning with `-` cannot be parsed as a CLI option such as the sandbox bypass flag. For Claude this also stops a trailing prompt being taken as another --add-dir value. - Scope the late-network regression to the network it created, since the main CI workflow runs test files in parallel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep early CONNECT payload and keep Docker suites out of parallel CI - The egress proxy stopped reading after parsing CONNECT but left its data listener attached, so bytes a client sent before the tunnel was established (such as a TLS ClientHello) were dropped unless they arrived in the header chunk. It now detaches and pauses until the tunnel is piped. An upstream error inside an established tunnel now resets the client instead of writing an HTTP status into the TLS stream, and a closed client tears down its upstream. - Allow tests to override the proxy's listen and upstream ports; the production defaults stay 3128 and 443. - Exclude the Docker agent suites from the main CI npm test, since the Agent isolation workflow already runs them one file at a time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Capture the network suite invocation after the image build createVendorNetwork now refuses an expired invocation, so capturing the suite's invocation at module load with a 60 s deadline could fail the whole file after a cold image build. Capture it in beforeAll after the build, with a deadline covering the suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bind vendor networks to daemon IDs and validate the proxy restart policy - Record the network and proxy container IDs returned at creation. Validation inspects those IDs and requires the network name and proxy endpoint to match them, so a removed-and-recreated network or proxy with identical attributes is refused. removeVendorNetwork deletes by ID and leaves a same-named stand-in in place, which surfaces as a cleanup failure. - Require the proxy's default no-restart policy, as for the agent and keeper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Apply the proxy header limit to headers only and bound profile checks - The egress proxy applied its 8 KiB limit to everything received so far, so a CONNECT header followed in the same read by a large ClientHello was refused with 431. The limit now covers only the header. - assertContainerProfile clamps its network revalidation budget to the invocation deadline, including for callers using the default budget. - Give the late-keeper regression enough budget to pass on a loaded daemon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Clean up failed network setup by ID and bound proxy header buffering - createVendorNetwork keeps the network and proxy IDs as soon as each create returns and uses them for the rest of setup and for failure cleanup. Names are used only for a create whose ID never came back, so a same-named stand-in carrying the allocation label is not deleted. - The proxy searches for the header end only within the 8 KiB limit and never keeps more than an unfinished header between reads, so a streamed unterminated header cannot grow its buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Run live auth probes through runContainer and guard network teardown - The opt-in authenticated Codex and Claude probes now launch through runContainer, so they exercise creation, validation and removal instead of raw docker create and start. The Claude token is passed only as the profile's secret. - The network suite skips teardown when setup never produced a network, so a setup failure is reported instead of a teardown TypeError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Authenticate profiles before destructive container cleanup removeContainerOrThrow now refuses a profile that the trusted builder did not register before running any Docker command, so a copied profile's public name and ownership label cannot remove another invocation's container. The launch paths already rejected copies through their first profileTimeout call; this makes the cleanup boundary enforce it itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a02d9cc commit f8c8d9f

14 files changed

Lines changed: 1095 additions & 163 deletions

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ jobs:
2828
cache: npm
2929
- run: npm ci --ignore-scripts
3030
- run: npm run typecheck
31-
- run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts
31+
# 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

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ jobs:
1616
cache: npm
1717
- run: npm ci --ignore-scripts
1818
- run: npm run typecheck
19-
- run: npm test
19+
# The Docker agent suites run one file at a time in the Agent isolation workflow; running them here
20+
# 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
2022
- run: npx playwright install --with-deps chromium
2123
- run: npm run test:browser

‎agents/container/Dockerfile‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \
1111
&& install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost \
1212
&& install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git
1313

14-
COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe
14+
COPY --chmod=0555 container/probe.sh /usr/local/bin/codeboost-container-probe
15+
COPY --chmod=0444 network/proxy.mjs /usr/local/lib/codeboost-egress-proxy.mjs
1516

1617
LABEL org.opencontainers.image.base.name="docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1" \
1718
io.codeboost.codex.version="0.153.4" \

‎agents/container/image.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { execFileSync } from 'node:child_process';
2-
import { dirname } from 'node:path';
2+
import { dirname, join } from 'node:path';
33
import { fileURLToPath } from 'node:url';
44

55
export const AGENT_IMAGE = 'codeboost-agent:node26-codex0.153.4-claude2.1.281';
66
export const BASE_IMAGE = 'docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1';
77
export const CODEX_VERSION = '0.153.4';
88
export const CLAUDE_VERSION = '2.1.281';
99

10-
const context = dirname(fileURLToPath(import.meta.url));
10+
const containerDirectory = dirname(fileURLToPath(import.meta.url));
11+
const context = dirname(containerDirectory);
1112
const trustedImages = new Set<string>();
1213

1314
export function assertBuiltAgentImage(imageId: string): void {
@@ -22,7 +23,8 @@ export function buildAgentImage(timeoutMs = 10 * 60_000): string {
2223
if (value <= 0) throw new Error('Agent image build exceeded its overall deadline.');
2324
return value;
2425
};
25-
execFileSync('docker', ['build', '--pull=false', '--tag', AGENT_IMAGE, context], {
26+
execFileSync('docker', ['build', '--pull=false', '--file', join(containerDirectory, 'Dockerfile'),
27+
'--tag', AGENT_IMAGE, context], {
2628
timeout: remaining(), killSignal: 'SIGKILL', stdio: ['ignore', 'inherit', 'inherit'],
2729
});
2830
const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], {

‎agents/container/profile.ts‎

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { join } from 'node:path';
66
import { assertCapturedInvocation, type InvocationInput, type Phase } from '../contract.ts';
77
import { assertBuiltAgentImage } from './image.ts';
88
import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts';
9+
import { assertVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../network/network.ts';
10+
import { assertAgentCommand, assertPhasePolicy, type AgentCommand, type PhasePolicy } from '../policy.ts';
911
export interface ContainerProfile {
1012
readonly name: string;
1113
readonly args: readonly string[];
@@ -17,15 +19,19 @@ export interface ContainerProfile {
1719
readonly codexAuthFile?: string;
1820
readonly command: readonly string[];
1921
readonly ownershipId: string;
22+
readonly network: VendorNetwork;
23+
readonly policy: PhasePolicy;
2024
}
2125
export interface ProfileOptions {
2226
readonly invocation: InvocationInput;
2327
readonly filesystems: TaskFilesystems;
2428
readonly inputDirectory: string;
25-
readonly command: readonly string[];
29+
readonly command: AgentCommand;
2630
readonly imageId: string;
2731
readonly codexAuthFile?: string;
2832
readonly claudeToken?: string;
33+
readonly network: VendorNetwork;
34+
readonly policy: PhasePolicy;
2935
}
3036

3137
interface FileIdentity {
@@ -40,10 +46,12 @@ interface FileIdentity {
4046
}
4147
interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity;
4248
readonly cleanupDirectories: readonly string[]; readonly filesystems: TaskFilesystems;
43-
readonly clone: InvocationInput['clone']; readonly deadline: number }
49+
readonly clone: InvocationInput['clone']; readonly deadline: number; readonly network: VendorNetwork;
50+
readonly policy: PhasePolicy; readonly invocation: InvocationInput }
4451
type InputIdentity = Pick<ProfileIdentity, 'inputDirectory' | 'schema'>;
4552
interface InputCapture extends InputIdentity { readonly content: Buffer }
4653
const identities = new WeakMap<ContainerProfile, ProfileIdentity>();
54+
const claimedNetworks = new WeakSet<VendorNetwork>();
4755
const removeOwnedDirectory = (directory: string) => {
4856
if (!lstatSync(directory, { throwIfNoEntry: false })) return;
4957
chmodSync(directory, 0o700);
@@ -105,10 +113,13 @@ const captureInput = (directory: string): InputCapture => {
105113
};
106114

107115
/** Internal authenticity and host-file revalidation used at every launch boundary. */
108-
export function assertContainerProfile(profile: ContainerProfile): void {
116+
export function assertContainerProfile(profile: ContainerProfile, timeoutMs = 30_000): void {
109117
const expected = identities.get(profile);
110118
if (!expected) throw new Error('Container profile was not created by the trusted profile builder.');
111119
assertTaskFilesystems(expected.filesystems, expected.clone);
120+
// Every caller, including those using the default budget, is bounded by the invocation deadline.
121+
assertVendorNetwork(expected.network, expected.invocation, profile.name, profileTimeout(profile, timeoutMs));
122+
assertPhasePolicy(expected.policy, expected.invocation);
112123
const actual = captureInput(expected.inputDirectory);
113124
if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema))
114125
throw new Error('Schema input changed after the profile was captured.');
@@ -118,6 +129,10 @@ export function assertContainerProfile(profile: ContainerProfile): void {
118129
}
119130
}
120131

132+
export function isContainerProfileAuthentic(profile: ContainerProfile): boolean {
133+
return identities.has(profile);
134+
}
135+
121136
/** Clamp a Docker budget to the captured invocation deadline, which no launch may outlive. */
122137
export function profileTimeout(profile: ContainerProfile, timeoutMs: number, now = Date.now()): number {
123138
const expected = identities.get(profile);
@@ -131,7 +146,10 @@ export function profileTimeout(profile: ContainerProfile, timeoutMs: number, now
131146
export function disposeContainerProfile(profile: ContainerProfile): void {
132147
const identity = identities.get(profile);
133148
if (!identity) return;
134-
removeOwnedDirectories(identity.cleanupDirectories);
149+
const failures: unknown[] = [];
150+
try { removeOwnedDirectories(identity.cleanupDirectories); } catch (error) { failures.push(error); }
151+
try { removeVendorNetwork(identity.network); } catch (error) { failures.push(error); }
152+
if (failures.length) throw new AggregateError(failures, 'Profile resource cleanup did not settle.');
135153
identities.delete(profile);
136154
}
137155

@@ -150,26 +168,32 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
150168
const { invocation, filesystems } = options;
151169
// Phase, vendor and deadline drive mount modes and credentials, so they must come from a captured request.
152170
assertCapturedInvocation(invocation);
153-
if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0')))
154-
throw new Error('Container command must be a complete literal argv array.');
155171
if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId))
156172
throw new Error('Container profile requires the immutable built image ID.');
157173
assertBuiltAgentImage(options.imageId);
158174
assertTaskFilesystems(filesystems, invocation.clone);
159-
const sourceInput = captureInput(options.inputDirectory);
160-
if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken))
161-
throw new Error('Codex requires only its auth file.');
162-
if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile))
163-
throw new Error('Claude requires only its OAuth token.');
164-
if (options.claudeToken?.includes('\0')) throw new Error('Claude OAuth token is malformed.');
165-
if (!/^codeboost-work-[0-9a-f-]+$/.test(filesystems.workVolume)
166-
|| !/^codeboost-metadata-[0-9a-f-]+$/.test(filesystems.metadataVolume)
167-
|| !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.');
168-
// Read through one no-follow descriptor so the path cannot be swapped between check and open.
169-
const sourceAuth = options.codexAuthFile ? readCapturedFile(options.codexAuthFile, 'Codex auth') : undefined;
175+
const invocationLeft = Math.floor(invocation.deadline - Date.now());
176+
if (invocationLeft < 1) throw new Error('Invocation deadline has passed.');
177+
assertVendorNetwork(options.network, invocation, undefined, Math.min(30_000, invocationLeft));
178+
if (claimedNetworks.has(options.network)) throw new Error('Vendor network already belongs to another container profile.');
179+
// Own the network from here on, so any later failure removes it rather than leaking it.
180+
claimedNetworks.add(options.network);
170181
const cleanupDirectories: string[] = [];
171182
let codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined;
172183
try {
184+
assertPhasePolicy(options.policy, invocation);
185+
const command = assertAgentCommand(options.command, options.policy, invocation.vendor);
186+
const sourceInput = captureInput(options.inputDirectory);
187+
if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken))
188+
throw new Error('Codex requires only its auth file.');
189+
if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile))
190+
throw new Error('Claude requires only its OAuth token.');
191+
if (options.claudeToken?.includes('\0')) throw new Error('Claude OAuth token is malformed.');
192+
if (!/^codeboost-work-[0-9a-f-]+$/.test(filesystems.workVolume)
193+
|| !/^codeboost-metadata-[0-9a-f-]+$/.test(filesystems.metadataVolume)
194+
|| !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.');
195+
// Read through one no-follow descriptor so the path cannot be swapped between check and open.
196+
const sourceAuth = options.codexAuthFile ? readCapturedFile(options.codexAuthFile, 'Codex auth') : undefined;
173197
const inputDirectory = mkdtempSync(join(tmpdir(), 'codeboost-input-'));
174198
cleanupDirectories.push(inputDirectory);
175199
writeFileSync(join(inputDirectory, 'schema.json'), sourceInput.content,
@@ -191,9 +215,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
191215
const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL',
192216
'--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--pids-limit=128', '--memory=512m', '--memory-swap=512m',
193217
'--cpus=1', '--shm-size=16m', '--ipc=private', '--cgroupns=private',
194-
'--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`,
218+
`--network=${options.network.name}`, '--dns=127.0.0.1', '--env', 'HOME=/home/codeboost',
219+
'--env', `CODEBOOST_PHASE=${invocation.phase}`,
195220
'--label', `io.codeboost.invocation=${ownershipId}`,
196221
'--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache',
222+
'--env', `HTTPS_PROXY=${options.network.proxyUrl}`, '--env', `HTTP_PROXY=${options.network.proxyUrl}`,
223+
'--env', 'NO_PROXY=localhost,127.0.0.1',
197224
'--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`,
198225
'--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`,
199226
'--env', 'XDG_CACHE_HOME=/tmp/xdg-cache',
@@ -207,20 +234,22 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
207234
'--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700',
208235
'--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true }));
209236
} else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN');
210-
args.push(options.imageId, ...options.command);
237+
args.push(options.imageId, ...command);
211238
const capturedFilesystems = filesystems;
212239
const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId,
213240
phase: invocation.phase, vendor: invocation.vendor,
214241
filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile,
215-
command: Object.freeze([...options.command]), ownershipId });
242+
command: Object.freeze([...command]), ownershipId, network: options.network, policy: options.policy });
216243
identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema,
217244
auth: authIdentity,
218245
cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone,
219-
deadline: invocation.deadline }));
246+
deadline: invocation.deadline, network: options.network, policy: options.policy, invocation }));
220247
return profile;
221248
} catch (error) {
222-
try { removeOwnedDirectories(cleanupDirectories); }
223-
catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Profile creation and cleanup both failed.'); }
249+
const failures: unknown[] = [];
250+
try { removeOwnedDirectories(cleanupDirectories); } catch (cleanupError) { failures.push(cleanupError); }
251+
try { removeVendorNetwork(options.network); } catch (cleanupError) { failures.push(cleanupError); }
252+
if (failures.length) throw new AggregateError([error, ...failures], 'Profile creation and cleanup both failed.');
224253
throw error;
225254
}
226255
}

0 commit comments

Comments
 (0)