Skip to content

Commit 353efb0

Browse files
committed
Add vendor-only egress and phase policy
1 parent a4bd301 commit 353efb0

11 files changed

Lines changed: 426 additions & 50 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ jobs:
2222
cache: npm
2323
- run: npm ci --ignore-scripts
2424
- run: npm run typecheck
25-
- run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts
25+
- run: npx vitest run 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

‎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: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { join } from 'node:path';
66
import type { InvocationInput, Phase } from '../contract.ts';
77
import { assertBuiltAgentImage } from './image.ts';
88
import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts';
9+
import { assertVendorNetwork, type VendorNetwork } from '../network/network.ts';
10+
import { assertPhasePolicy, type PhasePolicy } from '../policy.ts';
911
export interface ContainerProfile {
1012
readonly name: string;
1113
readonly args: readonly string[];
@@ -17,6 +19,8 @@ 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;
@@ -26,6 +30,8 @@ export interface ProfileOptions {
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,7 +46,8 @@ 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'] }
49+
readonly clone: InvocationInput['clone']; readonly network: VendorNetwork; readonly policy: PhasePolicy;
50+
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>();
@@ -105,6 +112,8 @@ export function assertContainerProfile(profile: ContainerProfile): void {
105112
const expected = identities.get(profile);
106113
if (!expected) throw new Error('Container profile was not created by the trusted profile builder.');
107114
assertTaskFilesystems(expected.filesystems, expected.clone);
115+
assertVendorNetwork(expected.network, profile.vendor);
116+
assertPhasePolicy(expected.policy, expected.invocation);
108117
const actual = captureInput(expected.inputDirectory);
109118
if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema))
110119
throw new Error('Schema input changed after the profile was captured.');
@@ -141,6 +150,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
141150
throw new Error('Container profile requires the immutable built image ID.');
142151
assertBuiltAgentImage(options.imageId);
143152
assertTaskFilesystems(filesystems, invocation.clone);
153+
assertVendorNetwork(options.network, invocation.vendor);
154+
assertPhasePolicy(options.policy, invocation);
144155
const sourceInput = captureInput(options.inputDirectory);
145156
if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken))
146157
throw new Error('Codex requires only its auth file.');
@@ -177,9 +188,11 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
177188
const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL',
178189
'--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--memory-swap=512m',
179190
'--cpus=1', '--shm-size=16m',
180-
'--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`,
191+
`--network=${options.network.name}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`,
181192
'--label', `io.codeboost.invocation=${ownershipId}`,
182193
'--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache',
194+
'--env', `HTTPS_PROXY=${options.network.proxyUrl}`, '--env', `HTTP_PROXY=${options.network.proxyUrl}`,
195+
'--env', 'NO_PROXY=localhost,127.0.0.1',
183196
'--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`,
184197
'--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`,
185198
'--env', 'XDG_CACHE_HOME=/tmp/xdg-cache',
@@ -198,10 +211,11 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
198211
const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId,
199212
phase: invocation.phase, vendor: invocation.vendor,
200213
filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile,
201-
command: Object.freeze([...options.command]), ownershipId });
214+
command: Object.freeze([...options.command]), ownershipId, network: options.network, policy: options.policy });
202215
identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema,
203216
auth: authIdentity,
204-
cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone }));
217+
cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone,
218+
network: options.network, policy: options.policy, invocation }));
205219
return profile;
206220
} catch (error) {
207221
try { removeOwnedDirectories(cleanupDirectories); }

‎agents/container/run.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ type Inspect = {
8989
Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record<string, string> | null;
9090
Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null };
9191
Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>;
92+
NetworkSettings: { Networks: Record<string, unknown> };
9293
};
9394

9495
/** Validate daemon-resolved configuration before starting an agent. */
@@ -117,7 +118,7 @@ export function validateContainer(container: string, profile: ContainerProfile,
117118
|| !host.ReadonlyRootfs || host.Privileged
118119
|| !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0
119120
|| !exactNoNewPrivileges(host.SecurityOpt)
120-
|| host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private'
121+
|| host.NetworkMode !== profile.network.name || host.PidMode !== '' || host.IpcMode !== 'private'
121122
|| host.UTSMode !== '' || host.UsernsMode !== '' || host.CgroupnsMode !== 'private'
122123
|| (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128
123124
|| host.Memory !== 512 * 1024 * 1024 || host.MemorySwap !== 512 * 1024 * 1024
@@ -130,6 +131,8 @@ export function validateContainer(container: string, profile: ContainerProfile,
130131
|| host.CpuCount !== 0 || host.CpuPercent !== 0 || host.IOMaximumBandwidth !== 0 || host.IOMaximumIOps !== 0
131132
|| host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== '')
132133
throw new Error('Container daemon configuration is missing required lockdown.');
134+
if (JSON.stringify(Object.keys(inspect.NetworkSettings.Networks)) !== JSON.stringify([profile.network.name]))
135+
throw new Error('Container network attachment changed.');
133136
const tmpfs = host.Tmpfs ?? {};
134137
const expectedTmpfs = new Map([
135138
['/tmp', ['rw', 'nosuid', 'nodev', 'size=33554432', 'nr_inodes=4096', 'mode=1777']],
@@ -204,7 +207,8 @@ export function validateContainer(container: string, profile: ContainerProfile,
204207
const imageEnvironment = new Map((image?.Config?.Env ?? []).map(value => [value.slice(0, value.indexOf('=')), value.slice(value.indexOf('=') + 1)]));
205208
const allowedEnvironment = new Set(['PATH', 'NODE_VERSION', 'YARN_VERSION', 'HOME', 'CODEBOOST_PHASE', 'CODEBOOST_VENDOR',
206209
'CODEBOOST_WORK_BYTES', 'CODEBOOST_WORK_INODES', 'CODEBOOST_METADATA_BYTES', 'CODEBOOST_METADATA_INODES',
207-
'npm_config_cache', 'XDG_CACHE_HOME', ...(profile.vendor === 'codex' ? ['CODEX_HOME'] : ['CLAUDE_CODE_OAUTH_TOKEN'])]);
210+
'npm_config_cache', 'XDG_CACHE_HOME', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY',
211+
...(profile.vendor === 'codex' ? ['CODEX_HOME'] : ['CLAUDE_CODE_OAUTH_TOKEN'])]);
208212
if (new Set(names).size !== names.length || names.some(name => !allowedEnvironment.has(name)))
209213
throw new Error('Container includes an unexpected environment variable.');
210214
if (environment.get('PATH') !== imageEnvironment.get('PATH')
@@ -215,7 +219,10 @@ export function validateContainer(container: string, profile: ContainerProfile,
215219
|| environment.get('CODEBOOST_METADATA_BYTES') !== String(profile.filesystems.metadataBytes)
216220
|| environment.get('CODEBOOST_METADATA_INODES') !== String(profile.filesystems.metadataInodes)
217221
|| environment.get('npm_config_cache') !== '/tmp/npm-cache'
218-
|| environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache')
222+
|| environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache'
223+
|| environment.get('HTTPS_PROXY') !== profile.network.proxyUrl
224+
|| environment.get('HTTP_PROXY') !== profile.network.proxyUrl
225+
|| environment.get('NO_PROXY') !== 'localhost,127.0.0.1')
219226
throw new Error('Container isolation environment changed.');
220227
if (profile.vendor === 'codex' && (names.includes('CLAUDE_CODE_OAUTH_TOKEN')
221228
|| environment.get('CODEX_HOME') !== '/run/codeboost-auth/codex'))

‎agents/network/network.ts‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { execFileSync, spawnSync } from 'node:child_process';
2+
import { randomUUID } from 'node:crypto';
3+
import type { InvocationInput } from '../contract.ts';
4+
import { assertBuiltAgentImage } from '../container/image.ts';
5+
6+
export const VENDOR_HOSTS = Object.freeze({
7+
claude: Object.freeze(['api.anthropic.com']),
8+
codex: Object.freeze(['api.openai.com', 'chatgpt.com']),
9+
} satisfies Record<InvocationInput['vendor'], readonly string[]>);
10+
11+
export interface VendorNetwork {
12+
readonly name: string;
13+
readonly proxyContainer: string;
14+
readonly proxyUrl: string;
15+
readonly vendor: InvocationInput['vendor'];
16+
}
17+
interface NetworkIdentity { readonly allocationId: string; readonly imageId: string }
18+
const identities = new WeakMap<VendorNetwork, NetworkIdentity>();
19+
const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST });
20+
const deadline = (timeoutMs: number) => {
21+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Network deadline must be a positive integer.');
22+
const end = performance.now() + timeoutMs;
23+
return () => {
24+
const value = Math.ceil(end - performance.now());
25+
if (value <= 0) throw new Error('Vendor network operation exceeded its overall deadline.');
26+
return value;
27+
};
28+
};
29+
const docker = (args: readonly string[], timeout: number) => execFileSync('docker', [...args], {
30+
encoding: 'utf8', timeout, killSignal: 'SIGKILL', env: environment(), stdio: ['ignore', 'pipe', 'pipe'],
31+
}).trim();
32+
const absent = (result: ReturnType<typeof spawnSync>) => result.status !== 0 && !result.error
33+
&& /No such (?:object|container|network)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
34+
const remove = (args: readonly string[], inspect: readonly string[], remaining: () => number, kind: string,
35+
allocationId: string) => {
36+
const before = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL',
37+
env: environment(), stdio: ['ignore', 'pipe', 'pipe'] });
38+
if (before.status !== 0) {
39+
if (absent(before)) return;
40+
throw new Error(`Failed to establish ownership of ${kind}.`);
41+
}
42+
const inspected = JSON.parse(before.stdout || '[]')[0] as
43+
{ Labels?: Record<string, string>; Config?: { Labels?: Record<string, string> } } | undefined;
44+
const labels = inspected?.Labels ?? inspected?.Config?.Labels;
45+
if (labels?.['io.codeboost.egress'] !== allocationId) throw new Error(`Refused to remove unowned ${kind}.`);
46+
const result = spawnSync('docker', [...args], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL',
47+
env: environment(), stdio: ['ignore', 'pipe', 'pipe'] });
48+
if (result.status === 0) return;
49+
const check = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL',
50+
env: environment(), stdio: ['ignore', 'pipe', 'pipe'] });
51+
if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`);
52+
};
53+
54+
export function assertVendorNetwork(network: VendorNetwork, vendor?: InvocationInput['vendor']): void {
55+
const identity = identities.get(network);
56+
if (!identity) throw new Error('Vendor network was not created by the trusted network builder.');
57+
if (vendor && network.vendor !== vendor) throw new Error('Vendor network does not match the invocation vendor.');
58+
assertBuiltAgentImage(identity.imageId);
59+
}
60+
61+
export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: string,
62+
timeoutMs = 60_000): VendorNetwork {
63+
assertBuiltAgentImage(imageId);
64+
const remaining = deadline(timeoutMs), allocationId = randomUUID();
65+
const name = `codeboost-egress-${vendor}-${randomUUID()}`;
66+
const proxyContainer = `codeboost-proxy-${vendor}-${randomUUID()}`;
67+
let networkPlanned = false, proxyPlanned = false;
68+
try {
69+
networkPlanned = true;
70+
docker(['network', 'create', '--internal', '--driver', 'bridge',
71+
'--label', `io.codeboost.egress=${allocationId}`, name], remaining());
72+
proxyPlanned = true;
73+
docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001',
74+
'--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m',
75+
'--cpus=.25', '--network', name, '--network-alias', 'codeboost-proxy',
76+
'--label', `io.codeboost.egress=${allocationId}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`,
77+
'--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs'], remaining());
78+
docker(['network', 'connect', 'bridge', proxyContainer], remaining());
79+
docker(['exec', proxyContainer, 'node', '-e', [
80+
"const net=require('node:net');let attempts=0;",
81+
"const check=()=>{const socket=net.connect(3128,'127.0.0.1');",
82+
"socket.once('connect',()=>{socket.destroy();process.exit(0)});",
83+
"socket.once('error',()=>{socket.destroy();if(++attempts===50)process.exit(1);setTimeout(check,20)})};check();",
84+
].join('')], remaining());
85+
const inspect = JSON.parse(docker(['container', 'inspect', proxyContainer], remaining()))[0] as
86+
{ State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record<string, string>; Env?: string[] };
87+
HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null;
88+
SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number };
89+
NetworkSettings?: { Networks?: Record<string, unknown> }; Mounts?: unknown[] } | undefined;
90+
const inspectedNetwork = JSON.parse(docker(['network', 'inspect', name], remaining()))[0] as
91+
{ Internal?: boolean; Driver?: string; Labels?: Record<string, string> } | undefined;
92+
const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort();
93+
if (!inspect?.State?.Running || inspect.Config?.Image !== imageId || inspect.Config?.User !== '10001:10001'
94+
|| inspect.Config?.Labels?.['io.codeboost.egress'] !== allocationId || !inspect.HostConfig?.ReadonlyRootfs
95+
|| inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL')
96+
|| (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1
97+
|| !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '')
98+
|| inspect.HostConfig.Memory !== 64 * 1024 * 1024
99+
|| inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000
100+
|| JSON.stringify(networks) !== JSON.stringify(['bridge', name].sort())
101+
|| inspect.Mounts?.length || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`)
102+
|| !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge'
103+
|| inspectedNetwork.Labels?.['io.codeboost.egress'] !== allocationId)
104+
throw new Error('Vendor proxy does not match its pinned isolation profile.');
105+
remaining();
106+
const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor });
107+
identities.set(network, Object.freeze({ allocationId, imageId }));
108+
return network;
109+
} catch (error) {
110+
const failures: unknown[] = [];
111+
if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer],
112+
deadline(30_000), 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); }
113+
if (networkPlanned) try { remove(['network', 'rm', name], ['network', 'inspect', name],
114+
deadline(30_000), 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); }
115+
if (failures.length) throw new AggregateError([error, ...failures], 'Vendor network creation and cleanup failed.');
116+
throw error;
117+
}
118+
}
119+
120+
export function removeVendorNetwork(network: VendorNetwork): void {
121+
assertVendorNetwork(network);
122+
const allocationId = identities.get(network)!.allocationId;
123+
const remaining = deadline(30_000), failures: unknown[] = [];
124+
try { remove(['rm', '--force', network.proxyContainer], ['container', 'inspect', network.proxyContainer],
125+
remaining, 'vendor proxy', allocationId); } catch (error) { failures.push(error); }
126+
try { remove(['network', 'rm', network.name], ['network', 'inspect', network.name],
127+
remaining, 'vendor network', allocationId); } catch (error) { failures.push(error); }
128+
if (failures.length) throw new AggregateError(failures, 'Vendor network cleanup did not settle.');
129+
identities.delete(network);
130+
}

0 commit comments

Comments
 (0)