Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
abed204
Add vendor-only egress and phase policy
mchwang Sep 24, 2026
84159b7
Enforce invocation-scoped agent policy
mchwang Sep 24, 2026
5cbe6d1
Bind adapters and proxy checks to invocation
mchwang Sep 24, 2026
22faf3a
Block agent DNS and harden proxy validation
mchwang Sep 24, 2026
a5f0c05
Close remaining network lifecycle gaps
mchwang Sep 24, 2026
9aa1b89
Stabilize live Claude marker probe
mchwang Sep 24, 2026
263cc57
Port D2 regression tests to named isolation probes
mchwang Sep 25, 2026
cd58af2
Carry D2 seccomp pinning into the egress proxy
mchwang Sep 25, 2026
52eb54f
Own vendor networks on profile failure and bound network cleanup
mchwang Sep 25, 2026
52af54b
Carry D2 deadline, runtime and capture rules into D3
mchwang Sep 25, 2026
2df056a
Require captured invocations for policies and serialize Docker suites
mchwang Sep 25, 2026
5799d70
Bound network checks by the invocation deadline and reconcile late cr…
mchwang Sep 25, 2026
b57659e
End CLI option parsing before agent prompts
mchwang Sep 25, 2026
c31e790
Keep early CONNECT payload and keep Docker suites out of parallel CI
mchwang Sep 25, 2026
9876b04
Capture the network suite invocation after the image build
mchwang Sep 25, 2026
e90b579
Bind vendor networks to daemon IDs and validate the proxy restart policy
mchwang Sep 25, 2026
e8e8d57
Apply the proxy header limit to headers only and bound profile checks
mchwang Sep 25, 2026
647a0bb
Clean up failed network setup by ID and bound proxy header buffering
mchwang Sep 25, 2026
75fdee9
Run live auth probes through runContainer and guard network teardown
mchwang Sep 25, 2026
a2852e8
Authenticate profiles before destructive container cleanup
mchwang Sep 25, 2026
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
3 changes: 2 additions & 1 deletion .github/workflows/agent-isolation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ jobs:
cache: npm
- run: npm ci --ignore-scripts
- run: npm run typecheck
- run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts
# 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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
cache: npm
- run: npm ci --ignore-scripts
- run: npm run typecheck
- run: npm test
# 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
- run: npx playwright install --with-deps chromium
- run: npm run test:browser
3 changes: 2 additions & 1 deletion agents/container/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \
&& install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost \
&& install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git

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

LABEL org.opencontainers.image.base.name="docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1" \
io.codeboost.codex.version="0.153.4" \
Expand Down
8 changes: 5 additions & 3 deletions agents/container/image.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { execFileSync } from 'node:child_process';
import { dirname } from 'node:path';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

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

const context = dirname(fileURLToPath(import.meta.url));
const containerDirectory = dirname(fileURLToPath(import.meta.url));
const context = dirname(containerDirectory);
const trustedImages = new Set<string>();

export function assertBuiltAgentImage(imageId: string): void {
Expand All @@ -22,7 +23,8 @@ export function buildAgentImage(timeoutMs = 10 * 60_000): string {
if (value <= 0) throw new Error('Agent image build exceeded its overall deadline.');
return value;
};
execFileSync('docker', ['build', '--pull=false', '--tag', AGENT_IMAGE, context], {
execFileSync('docker', ['build', '--pull=false', '--file', join(containerDirectory, 'Dockerfile'),
'--tag', AGENT_IMAGE, context], {
timeout: remaining(), killSignal: 'SIGKILL', stdio: ['ignore', 'inherit', 'inherit'],
});
const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], {
Expand Down
75 changes: 52 additions & 23 deletions agents/container/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { join } from 'node:path';
import { assertCapturedInvocation, type InvocationInput, type Phase } from '../contract.ts';
import { assertBuiltAgentImage } from './image.ts';
import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts';
import { assertVendorNetwork, removeVendorNetwork, type VendorNetwork } from '../network/network.ts';
import { assertAgentCommand, assertPhasePolicy, type AgentCommand, type PhasePolicy } from '../policy.ts';
export interface ContainerProfile {
readonly name: string;
readonly args: readonly string[];
Expand All @@ -17,15 +19,19 @@ export interface ContainerProfile {
readonly codexAuthFile?: string;
readonly command: readonly string[];
readonly ownershipId: string;
readonly network: VendorNetwork;
readonly policy: PhasePolicy;
}
export interface ProfileOptions {
readonly invocation: InvocationInput;
readonly filesystems: TaskFilesystems;
readonly inputDirectory: string;
readonly command: readonly string[];
readonly command: AgentCommand;
readonly imageId: string;
readonly codexAuthFile?: string;
readonly claudeToken?: string;
readonly network: VendorNetwork;
readonly policy: PhasePolicy;
}

interface FileIdentity {
Expand All @@ -40,10 +46,12 @@ interface FileIdentity {
}
interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity;
readonly cleanupDirectories: readonly string[]; readonly filesystems: TaskFilesystems;
readonly clone: InvocationInput['clone']; readonly deadline: number }
readonly clone: InvocationInput['clone']; readonly deadline: number; readonly network: VendorNetwork;
readonly policy: PhasePolicy; readonly invocation: InvocationInput }
type InputIdentity = Pick<ProfileIdentity, 'inputDirectory' | 'schema'>;
interface InputCapture extends InputIdentity { readonly content: Buffer }
const identities = new WeakMap<ContainerProfile, ProfileIdentity>();
const claimedNetworks = new WeakSet<VendorNetwork>();
const removeOwnedDirectory = (directory: string) => {
if (!lstatSync(directory, { throwIfNoEntry: false })) return;
chmodSync(directory, 0o700);
Expand Down Expand Up @@ -105,10 +113,13 @@ const captureInput = (directory: string): InputCapture => {
};

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

export function isContainerProfileAuthentic(profile: ContainerProfile): boolean {
return identities.has(profile);
}

/** Clamp a Docker budget to the captured invocation deadline, which no launch may outlive. */
export function profileTimeout(profile: ContainerProfile, timeoutMs: number, now = Date.now()): number {
const expected = identities.get(profile);
Expand All @@ -131,7 +146,10 @@ export function profileTimeout(profile: ContainerProfile, timeoutMs: number, now
export function disposeContainerProfile(profile: ContainerProfile): void {
const identity = identities.get(profile);
if (!identity) return;
removeOwnedDirectories(identity.cleanupDirectories);
const failures: unknown[] = [];
try { removeOwnedDirectories(identity.cleanupDirectories); } catch (error) { failures.push(error); }
try { removeVendorNetwork(identity.network); } catch (error) { failures.push(error); }
if (failures.length) throw new AggregateError(failures, 'Profile resource cleanup did not settle.');
identities.delete(profile);
}

Expand All @@ -150,26 +168,32 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
const { invocation, filesystems } = options;
// Phase, vendor and deadline drive mount modes and credentials, so they must come from a captured request.
assertCapturedInvocation(invocation);
if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0')))
throw new Error('Container command must be a complete literal argv array.');
if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId))
throw new Error('Container profile requires the immutable built image ID.');
assertBuiltAgentImage(options.imageId);
assertTaskFilesystems(filesystems, invocation.clone);
const sourceInput = captureInput(options.inputDirectory);
if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken))
throw new Error('Codex requires only its auth file.');
if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile))
throw new Error('Claude requires only its OAuth token.');
if (options.claudeToken?.includes('\0')) throw new Error('Claude OAuth token is malformed.');
if (!/^codeboost-work-[0-9a-f-]+$/.test(filesystems.workVolume)
|| !/^codeboost-metadata-[0-9a-f-]+$/.test(filesystems.metadataVolume)
|| !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.');
// Read through one no-follow descriptor so the path cannot be swapped between check and open.
const sourceAuth = options.codexAuthFile ? readCapturedFile(options.codexAuthFile, 'Codex auth') : undefined;
const invocationLeft = Math.floor(invocation.deadline - Date.now());
if (invocationLeft < 1) throw new Error('Invocation deadline has passed.');
assertVendorNetwork(options.network, invocation, undefined, Math.min(30_000, invocationLeft));
if (claimedNetworks.has(options.network)) throw new Error('Vendor network already belongs to another container profile.');
// Own the network from here on, so any later failure removes it rather than leaking it.
claimedNetworks.add(options.network);
const cleanupDirectories: string[] = [];
let codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined;
try {
assertPhasePolicy(options.policy, invocation);
const command = assertAgentCommand(options.command, options.policy, invocation.vendor);
const sourceInput = captureInput(options.inputDirectory);
if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken))
throw new Error('Codex requires only its auth file.');
if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile))
throw new Error('Claude requires only its OAuth token.');
if (options.claudeToken?.includes('\0')) throw new Error('Claude OAuth token is malformed.');
if (!/^codeboost-work-[0-9a-f-]+$/.test(filesystems.workVolume)
|| !/^codeboost-metadata-[0-9a-f-]+$/.test(filesystems.metadataVolume)
|| !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.');
// Read through one no-follow descriptor so the path cannot be swapped between check and open.
const sourceAuth = options.codexAuthFile ? readCapturedFile(options.codexAuthFile, 'Codex auth') : undefined;
const inputDirectory = mkdtempSync(join(tmpdir(), 'codeboost-input-'));
cleanupDirectories.push(inputDirectory);
writeFileSync(join(inputDirectory, 'schema.json'), sourceInput.content,
Expand All @@ -191,9 +215,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL',
'--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--pids-limit=128', '--memory=512m', '--memory-swap=512m',
'--cpus=1', '--shm-size=16m', '--ipc=private', '--cgroupns=private',
'--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`,
`--network=${options.network.name}`, '--dns=127.0.0.1', '--env', 'HOME=/home/codeboost',
'--env', `CODEBOOST_PHASE=${invocation.phase}`,
'--label', `io.codeboost.invocation=${ownershipId}`,
'--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache',
'--env', `HTTPS_PROXY=${options.network.proxyUrl}`, '--env', `HTTP_PROXY=${options.network.proxyUrl}`,
'--env', 'NO_PROXY=localhost,127.0.0.1',
'--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`,
'--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`,
'--env', 'XDG_CACHE_HOME=/tmp/xdg-cache',
Expand All @@ -207,20 +234,22 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
'--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700',
'--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true }));
} else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN');
args.push(options.imageId, ...options.command);
args.push(options.imageId, ...command);
const capturedFilesystems = filesystems;
const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId,
phase: invocation.phase, vendor: invocation.vendor,
filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile,
command: Object.freeze([...options.command]), ownershipId });
command: Object.freeze([...command]), ownershipId, network: options.network, policy: options.policy });
identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema,
auth: authIdentity,
cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone,
deadline: invocation.deadline }));
deadline: invocation.deadline, network: options.network, policy: options.policy, invocation }));
return profile;
} catch (error) {
try { removeOwnedDirectories(cleanupDirectories); }
catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Profile creation and cleanup both failed.'); }
const failures: unknown[] = [];
try { removeOwnedDirectories(cleanupDirectories); } catch (cleanupError) { failures.push(cleanupError); }
try { removeVendorNetwork(options.network); } catch (cleanupError) { failures.push(cleanupError); }
if (failures.length) throw new AggregateError([error, ...failures], 'Profile creation and cleanup both failed.');
throw error;
}
}
Loading
Loading