Skip to content

Commit 9bf088a

Browse files
committed
Enforce invocation-scoped agent policy
1 parent e0e9a2d commit 9bf088a

6 files changed

Lines changed: 216 additions & 132 deletions

File tree

‎agents/container/profile.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { InvocationInput, Phase } from '../contract.ts';
77
import { assertBuiltAgentImage } from './image.ts';
88
import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts';
99
import { assertVendorNetwork, type VendorNetwork } from '../network/network.ts';
10-
import { assertPhasePolicy, type PhasePolicy } from '../policy.ts';
10+
import { assertAgentCommand, assertPhasePolicy, type AgentCommand, type PhasePolicy } from '../policy.ts';
1111
export interface ContainerProfile {
1212
readonly name: string;
1313
readonly args: readonly string[];
@@ -26,7 +26,7 @@ export interface ProfileOptions {
2626
readonly invocation: InvocationInput;
2727
readonly filesystems: TaskFilesystems;
2828
readonly inputDirectory: string;
29-
readonly command: readonly string[];
29+
readonly command: AgentCommand;
3030
readonly imageId: string;
3131
readonly codexAuthFile?: string;
3232
readonly claudeToken?: string;
@@ -112,7 +112,7 @@ export function assertContainerProfile(profile: ContainerProfile): void {
112112
const expected = identities.get(profile);
113113
if (!expected) throw new Error('Container profile was not created by the trusted profile builder.');
114114
assertTaskFilesystems(expected.filesystems, expected.clone);
115-
assertVendorNetwork(expected.network, profile.vendor);
115+
assertVendorNetwork(expected.network, expected.invocation, profile.name);
116116
assertPhasePolicy(expected.policy, expected.invocation);
117117
const actual = captureInput(expected.inputDirectory);
118118
if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema))
@@ -144,14 +144,13 @@ const mountSource = (path: string, kind: string) => {
144144

145145
export function createContainerProfile(options: ProfileOptions): ContainerProfile {
146146
const { invocation, filesystems } = options;
147-
if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0')))
148-
throw new Error('Container command must be a complete literal argv array.');
149147
if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId))
150148
throw new Error('Container profile requires the immutable built image ID.');
151149
assertBuiltAgentImage(options.imageId);
152150
assertTaskFilesystems(filesystems, invocation.clone);
153-
assertVendorNetwork(options.network, invocation.vendor);
151+
assertVendorNetwork(options.network, invocation);
154152
assertPhasePolicy(options.policy, invocation);
153+
const command = assertAgentCommand(options.command, options.policy);
155154
const sourceInput = captureInput(options.inputDirectory);
156155
if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken))
157156
throw new Error('Codex requires only its auth file.');
@@ -206,12 +205,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
206205
'--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700',
207206
'--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true }));
208207
} else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN');
209-
args.push(options.imageId, ...options.command);
208+
args.push(options.imageId, ...command);
210209
const capturedFilesystems = filesystems;
211210
const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId,
212211
phase: invocation.phase, vendor: invocation.vendor,
213212
filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile,
214-
command: Object.freeze([...options.command]), ownershipId, network: options.network, policy: options.policy });
213+
command: Object.freeze([...command]), ownershipId, network: options.network, policy: options.policy });
215214
identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema,
216215
auth: authIdentity,
217216
cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone,

‎agents/network/network.ts‎

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export interface VendorNetwork {
1414
readonly proxyUrl: string;
1515
readonly vendor: InvocationInput['vendor'];
1616
}
17-
interface NetworkIdentity { readonly allocationId: string; readonly imageId: string }
17+
interface NetworkIdentity { readonly allocationId: string; readonly imageId: string; readonly invocation: InvocationInput;
18+
readonly subnet: string }
1819
const identities = new WeakMap<VendorNetwork, NetworkIdentity>();
1920
const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST });
2021
const deadline = (timeoutMs: number) => {
@@ -30,7 +31,7 @@ const docker = (args: readonly string[], timeout: number) => execFileSync('docke
3031
encoding: 'utf8', timeout, killSignal: 'SIGKILL', env: environment(), stdio: ['ignore', 'pipe', 'pipe'],
3132
}).trim();
3233
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+
&& /(?:No such (?:object|container|network)|network .* not found)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
3435
const remove = (args: readonly string[], inspect: readonly string[], remaining: () => number, kind: string,
3536
allocationId: string) => {
3637
const before = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL',
@@ -51,23 +52,56 @@ const remove = (args: readonly string[], inspect: readonly string[], remaining:
5152
if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`);
5253
};
5354

54-
export function assertVendorNetwork(network: VendorNetwork, vendor?: InvocationInput['vendor']): void {
55+
export function assertVendorNetwork(network: VendorNetwork, invocation?: InvocationInput, agentName?: string): void {
5556
const identity = identities.get(network);
5657
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+
if (invocation && (identity.invocation !== invocation || network.vendor !== invocation.vendor))
59+
throw new Error('Vendor network does not belong to this invocation.');
5860
assertBuiltAgentImage(identity.imageId);
61+
const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], 30_000))[0] as
62+
{ State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record<string, string>; Env?: string[];
63+
Entrypoint?: string[] | null; Cmd?: string[] | null };
64+
HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null;
65+
SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number };
66+
NetworkSettings?: { Networks?: Record<string, unknown> }; Mounts?: unknown[] } | undefined;
67+
const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], 30_000))[0] as
68+
{ Internal?: boolean; Driver?: string; Labels?: Record<string, string>; IPAM?: { Config?: Array<{ Subnet?: string }> };
69+
Containers?: Record<string, { Name?: string }> } | undefined;
70+
const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort();
71+
const endpoints = Object.values(inspectedNetwork?.Containers ?? {}).map(value => value.Name).sort();
72+
const allowedEndpoints = [network.proxyContainer, ...(agentName ? [agentName] : [])];
73+
if (!inspect?.State?.Running || inspect.Config?.Image !== identity.imageId || inspect.Config?.User !== '10001:10001'
74+
|| inspect.Config?.Labels?.['io.codeboost.egress'] !== identity.allocationId || !inspect.HostConfig?.ReadonlyRootfs
75+
|| inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL')
76+
|| (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1
77+
|| !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '')
78+
|| inspect.HostConfig.PidsLimit !== 64 || inspect.HostConfig.Memory !== 64 * 1024 * 1024
79+
|| inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000
80+
|| JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length
81+
|| inspect.Config?.Entrypoint?.[0] !== 'node'
82+
|| JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs'])
83+
|| inspect.Config.Env?.filter(value => value.startsWith('CODEBOOST_ALLOWED_HOSTS=')).length !== 1
84+
|| !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[network.vendor].join(',')}`)
85+
|| !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge'
86+
|| inspectedNetwork.Labels?.['io.codeboost.egress'] !== identity.allocationId
87+
|| inspectedNetwork.IPAM?.Config?.length !== 1 || inspectedNetwork.IPAM.Config[0]?.Subnet !== identity.subnet
88+
|| !endpoints.includes(network.proxyContainer) || endpoints.some(name => !name || !allowedEndpoints.includes(name)))
89+
throw new Error('Vendor network or proxy changed after allocation.');
5990
}
6091

61-
export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: string,
92+
export function createVendorNetwork(invocation: InvocationInput, imageId: string,
6293
timeoutMs = 60_000): VendorNetwork {
6394
assertBuiltAgentImage(imageId);
95+
const vendor = invocation.vendor;
6496
const remaining = deadline(timeoutMs), allocationId = randomUUID();
6597
const name = `codeboost-egress-${vendor}-${randomUUID()}`;
6698
const proxyContainer = `codeboost-proxy-${vendor}-${randomUUID()}`;
99+
const subnetSeed = randomUUID().replaceAll('-', '');
100+
const subnet = `10.254.${parseInt(subnetSeed.slice(0, 2), 16)}.${parseInt(subnetSeed.slice(2, 4), 16) & 0xf8}/29`;
67101
let networkPlanned = false, proxyPlanned = false;
68102
try {
69103
networkPlanned = true;
70-
docker(['network', 'create', '--internal', '--driver', 'bridge',
104+
docker(['network', 'create', '--internal', '--driver', 'bridge', '--subnet', subnet,
71105
'--label', `io.codeboost.egress=${allocationId}`, name], remaining());
72106
proxyPlanned = true;
73107
docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001',
@@ -82,29 +116,10 @@ export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId:
82116
"socket.once('connect',()=>{socket.destroy();process.exit(0)});",
83117
"socket.once('error',()=>{socket.destroy();if(++attempts===50)process.exit(1);setTimeout(check,20)})};check();",
84118
].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();
106119
const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor });
107-
identities.set(network, Object.freeze({ allocationId, imageId }));
120+
identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet }));
121+
assertVendorNetwork(network, invocation);
122+
remaining();
108123
return network;
109124
} catch (error) {
110125
const failures: unknown[] = [];
@@ -118,8 +133,10 @@ export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId:
118133
}
119134

120135
export function removeVendorNetwork(network: VendorNetwork): void {
121-
assertVendorNetwork(network);
122-
const allocationId = identities.get(network)!.allocationId;
136+
const identity = identities.get(network);
137+
if (!identity) throw new Error('Vendor network was not created by the trusted network builder.');
138+
assertBuiltAgentImage(identity.imageId);
139+
const allocationId = identity.allocationId;
123140
const remaining = deadline(30_000), failures: unknown[] = [];
124141
try { remove(['rm', '--force', network.proxyContainer], ['container', 'inspect', network.proxyContainer],
125142
remaining, 'vendor proxy', allocationId); } catch (error) { failures.push(error); }

‎agents/policy.ts‎

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,22 @@ export interface PhasePolicy {
99
readonly web: false;
1010
readonly mcp: false;
1111
}
12+
export interface AgentCommand { readonly argv: readonly string[] }
1213
interface PolicyIdentity { readonly invocation: InvocationInput }
1314
const identities = new WeakMap<PhasePolicy, PolicyIdentity>();
15+
const commands = new WeakMap<AgentCommand, PhasePolicy>();
16+
17+
const command = (policy: PhasePolicy, argv: readonly string[]): AgentCommand => {
18+
assertPhasePolicy(policy);
19+
const value = Object.freeze({ argv: Object.freeze([...argv]) });
20+
commands.set(value, policy);
21+
return value;
22+
};
23+
24+
export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy): readonly string[] {
25+
if (commands.get(value) !== policy) throw new Error('Container command was not generated for this phase policy.');
26+
return value.argv;
27+
}
1428

1529
export function createPhasePolicy(invocation: InvocationInput): PhasePolicy {
1630
const writable = invocation.phase === 'execute' || invocation.phase === 'fix';
@@ -43,19 +57,53 @@ export function dispatchApprovedCommand<T>(policy: PhasePolicy, argv: readonly s
4357
return execute(Object.freeze([...argv]));
4458
}
4559

46-
export function createClaudeCommand(policy: PhasePolicy, prompt: string): readonly string[] {
60+
export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentCommand {
4761
if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.');
4862
assertPhasePolicy(policy);
4963
const writable = policy.worktree === 'read-write';
5064
const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep';
51-
return Object.freeze(['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config',
65+
return command(policy, ['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config',
5266
'--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--no-chrome', '--permission-prompts', 'none',
53-
'--permission-mode', writable ? 'acceptEdits' : 'plan', '--allowedTools', allowed,
67+
'--permission-mode', writable ? 'acceptEdits' : 'plan', '--tools', allowed, '--allowedTools', allowed,
5468
'--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir', '/run/codeboost-input']);
5569
}
5670

5771
export function codexBaseArguments(policy: PhasePolicy): readonly string[] {
5872
assertPhasePolicy(policy);
5973
return Object.freeze(['codex', '--strict-config', '--config', 'web_search="disabled"',
60-
'--config', 'mcp_servers={}', '--ask-for-approval', 'never']);
74+
'--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']);
75+
}
76+
77+
export function createCodexCommand(policy: PhasePolicy, prompt: string): AgentCommand {
78+
if (!prompt || prompt.includes('\0')) throw new Error('Codex prompt must be nonempty and contain no NUL.');
79+
const sandbox = policy.worktree === 'read-write' ? 'workspace-write' : 'read-only';
80+
return command(policy, [...codexBaseArguments(policy), 'exec', '--sandbox', sandbox, '--skip-git-repo-check', prompt]);
81+
}
82+
83+
export type IsolationProbe = 'noop' | 'phase-worktree' | 'read-only-isolation' | 'persist-write'
84+
| 'persist-read' | 'capacity' | 'metadata' | 'must-not-run' | 'input-marker';
85+
86+
/** Fixed startup probes validate the sandbox itself without granting an agent a process tool. */
87+
export function createIsolationProbeCommand(policy: PhasePolicy, probe: IsolationProbe): AgentCommand {
88+
assertPhasePolicy(policy);
89+
const phase = policy.phase;
90+
const scripts: Record<Exclude<IsolationProbe, 'noop'>, string> = {
91+
'phase-worktree': policy.worktree === 'read-write'
92+
? `set -eu; printf ${phase} > /work/${phase}.txt; test -f /work/${phase}.txt`
93+
: `set -eu; ! touch /work/${phase}.txt 2>/dev/null; test ! -e /work/${phase}.txt`,
94+
'read-only-isolation': 'set -eu; test "$(id -u)" = 10001; test "$(git status --porcelain)" = ""; '
95+
+ 'test -z "${HOST_SECRET_SENTINEL:-}"; ! touch /work/forbidden; ! touch /usr/bin/forbidden; '
96+
+ 'touch /tmp/allowed "$HOME/allowed"; printf isolated',
97+
'persist-write': 'set -eu; printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first',
98+
'persist-read': 'set -eu; test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain',
99+
capacity: 'set -eu; ! dd if=/dev/zero of=/work/overflow bs=1M count=32 2>/dev/null; rm -f /work/overflow; '
100+
+ 'mkdir /work/many; i=0; while touch "/work/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt 2000; done; '
101+
+ 'test "$i" -lt 2000; test "$(find /work/many -type f | wc -l)" -eq "$i"; rm -rf /work/many; printf bounded',
102+
metadata: 'set -eu; ! touch /work/.git/forbidden 2>/dev/null; ! ln /work/.git/HEAD /work/metadata-link 2>/dev/null; '
103+
+ '! mv /work/.git /work/replaced 2>/dev/null; git status --porcelain; printf metadata-safe',
104+
'must-not-run': 'touch /tmp/command-ran',
105+
'input-marker': 'set -eu; grep -q codeboost-schema-marker /run/codeboost-input/schema.json; '
106+
+ 'test ! -e /run/codeboost-input/extra.json',
107+
};
108+
return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]);
61109
}

0 commit comments

Comments
 (0)