Skip to content

Commit 9c0655f

Browse files
committed
Bind adapters and proxy checks to invocation
1 parent 9bf088a commit 9c0655f

7 files changed

Lines changed: 65 additions & 25 deletions

File tree

‎agents/container/profile.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,11 @@ const captureInput = (directory: string): InputCapture => {
108108
};
109109

110110
/** Internal authenticity and host-file revalidation used at every launch boundary. */
111-
export function assertContainerProfile(profile: ContainerProfile): void {
111+
export function assertContainerProfile(profile: ContainerProfile, timeoutMs = 30_000): 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, expected.invocation, profile.name);
115+
assertVendorNetwork(expected.network, expected.invocation, profile.name, timeoutMs);
116116
assertPhasePolicy(expected.policy, expected.invocation);
117117
const actual = captureInput(expected.inputDirectory);
118118
if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema))
@@ -150,7 +150,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
150150
assertTaskFilesystems(filesystems, invocation.clone);
151151
assertVendorNetwork(options.network, invocation);
152152
assertPhasePolicy(options.policy, invocation);
153-
const command = assertAgentCommand(options.command, options.policy);
153+
const command = assertAgentCommand(options.command, options.policy, invocation.vendor);
154154
const sourceInput = captureInput(options.inputDirectory);
155155
if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken))
156156
throw new Error('Codex requires only its auth file.');

‎agents/container/run.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ type Inspect = {
9696
/** Validate daemon-resolved configuration before starting an agent. */
9797
export function validateContainer(container: string, profile: ContainerProfile, timeoutMs = 30_000): void {
9898
const remaining = createDeadline(timeoutMs);
99-
assertContainerProfile(profile);
99+
assertContainerProfile(profile, remaining());
100100
const inspect = JSON.parse(docker(['container', 'inspect', container], { timeoutMs: remaining() }))[0] as Inspect | undefined;
101101
if (!inspect) throw new Error('Docker did not return the created container.');
102102
const image = JSON.parse(docker(['image', 'inspect', profile.expectedImage], { timeoutMs: remaining() }))[0] as
@@ -230,7 +230,7 @@ export function validateContainer(container: string, profile: ContainerProfile,
230230
throw new Error('Credential profiles must not be combined or redirected.');
231231
if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN')))
232232
throw new Error('Credential profiles must not be combined.');
233-
assertContainerProfile(profile);
233+
assertContainerProfile(profile, remaining());
234234
remaining();
235235
}
236236

@@ -239,10 +239,10 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs =
239239
const remaining = createDeadline(timeoutMs);
240240
try {
241241
validateSecrets(profile, secrets);
242-
assertContainerProfile(profile);
242+
assertContainerProfile(profile, remaining());
243243
docker(profile.args, { timeoutMs: remaining(), secrets });
244244
validateContainer(profile.name, profile, remaining());
245-
assertContainerProfile(profile);
245+
assertContainerProfile(profile, remaining());
246246
remaining();
247247
return profile.name;
248248
} catch (error) {
@@ -258,7 +258,7 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000,
258258
const container = createValidatedContainer(profile, remaining(), secrets);
259259
let failure: unknown;
260260
try {
261-
assertContainerProfile(profile);
261+
assertContainerProfile(profile, remaining());
262262
const output = docker(['start', '--attach', container], { timeoutMs: remaining(), secrets });
263263
remaining();
264264
return output;

‎agents/network/network.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,22 @@ const remove = (args: readonly string[], inspect: readonly string[], remaining:
5252
if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`);
5353
};
5454

55-
export function assertVendorNetwork(network: VendorNetwork, invocation?: InvocationInput, agentName?: string): void {
55+
const validateVendorNetwork = (network: VendorNetwork, invocation: InvocationInput | undefined,
56+
agentName: string | undefined, remaining: () => number): void => {
5657
const identity = identities.get(network);
5758
if (!identity) throw new Error('Vendor network was not created by the trusted network builder.');
5859
if (invocation && (identity.invocation !== invocation || network.vendor !== invocation.vendor))
5960
throw new Error('Vendor network does not belong to this invocation.');
6061
assertBuiltAgentImage(identity.imageId);
61-
const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], 30_000))[0] as
62+
const inspect = JSON.parse(docker(['container', 'inspect', network.proxyContainer], remaining()))[0] as
6263
{ State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record<string, string>; Env?: string[];
6364
Entrypoint?: string[] | null; Cmd?: string[] | null };
6465
HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null;
65-
SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number };
66+
SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number; PidsLimit?: number;
67+
NetworkMode?: string; PidMode?: string; IpcMode?: string; UTSMode?: string; UsernsMode?: string;
68+
CgroupnsMode?: string; Devices?: unknown[] | null; DeviceRequests?: unknown[] | null };
6669
NetworkSettings?: { Networks?: Record<string, unknown> }; Mounts?: unknown[] } | undefined;
67-
const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], 30_000))[0] as
70+
const inspectedNetwork = JSON.parse(docker(['network', 'inspect', network.name], remaining()))[0] as
6871
{ Internal?: boolean; Driver?: string; Labels?: Record<string, string>; IPAM?: { Config?: Array<{ Subnet?: string }> };
6972
Containers?: Record<string, { Name?: string }> } | undefined;
7073
const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort();
@@ -77,6 +80,10 @@ export function assertVendorNetwork(network: VendorNetwork, invocation?: Invocat
7780
|| !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '')
7881
|| inspect.HostConfig.PidsLimit !== 64 || inspect.HostConfig.Memory !== 64 * 1024 * 1024
7982
|| inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000
83+
|| inspect.HostConfig.NetworkMode !== network.name || inspect.HostConfig.PidMode !== ''
84+
|| inspect.HostConfig.IpcMode !== 'private' || inspect.HostConfig.UTSMode !== ''
85+
|| inspect.HostConfig.UsernsMode !== '' || inspect.HostConfig.CgroupnsMode !== 'private'
86+
|| (inspect.HostConfig.Devices?.length ?? 0) !== 0 || (inspect.HostConfig.DeviceRequests?.length ?? 0) !== 0
8087
|| JSON.stringify(networks) !== JSON.stringify(['bridge', network.name].sort()) || inspect.Mounts?.length
8188
|| inspect.Config?.Entrypoint?.[0] !== 'node'
8289
|| JSON.stringify(inspect.Config?.Cmd) !== JSON.stringify(['/usr/local/lib/codeboost-egress-proxy.mjs'])
@@ -87,6 +94,12 @@ export function assertVendorNetwork(network: VendorNetwork, invocation?: Invocat
8794
|| inspectedNetwork.IPAM?.Config?.length !== 1 || inspectedNetwork.IPAM.Config[0]?.Subnet !== identity.subnet
8895
|| !endpoints.includes(network.proxyContainer) || endpoints.some(name => !name || !allowedEndpoints.includes(name)))
8996
throw new Error('Vendor network or proxy changed after allocation.');
97+
remaining();
98+
};
99+
100+
export function assertVendorNetwork(network: VendorNetwork, invocation?: InvocationInput, agentName?: string,
101+
timeoutMs = 30_000): void {
102+
validateVendorNetwork(network, invocation, agentName, deadline(timeoutMs));
90103
}
91104

92105
export function createVendorNetwork(invocation: InvocationInput, imageId: string,
@@ -118,7 +131,7 @@ export function createVendorNetwork(invocation: InvocationInput, imageId: string
118131
].join('')], remaining());
119132
const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor });
120133
identities.set(network, Object.freeze({ allocationId, imageId, invocation, subnet }));
121-
assertVendorNetwork(network, invocation);
134+
validateVendorNetwork(network, invocation, undefined, remaining);
122135
remaining();
123136
return network;
124137
} catch (error) {

‎agents/policy.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@ export interface PhasePolicy {
1212
export interface AgentCommand { readonly argv: readonly string[] }
1313
interface PolicyIdentity { readonly invocation: InvocationInput }
1414
const identities = new WeakMap<PhasePolicy, PolicyIdentity>();
15-
const commands = new WeakMap<AgentCommand, PhasePolicy>();
15+
const commands = new WeakMap<AgentCommand, { readonly policy: PhasePolicy; readonly vendor: InvocationInput['vendor'] }>();
1616

1717
const command = (policy: PhasePolicy, argv: readonly string[]): AgentCommand => {
1818
assertPhasePolicy(policy);
1919
const value = Object.freeze({ argv: Object.freeze([...argv]) });
20-
commands.set(value, policy);
20+
commands.set(value, Object.freeze({ policy, vendor: assertPhasePolicy(policy).vendor }));
2121
return value;
2222
};
2323

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.');
24+
export function assertAgentCommand(value: AgentCommand, policy: PhasePolicy,
25+
vendor?: InvocationInput['vendor']): readonly string[] {
26+
const identity = commands.get(value);
27+
if (identity?.policy !== policy || (vendor && identity.vendor !== vendor))
28+
throw new Error('Container command was not generated for this phase policy and vendor.');
2629
return value.argv;
2730
}
2831

@@ -59,7 +62,7 @@ export function dispatchApprovedCommand<T>(policy: PhasePolicy, argv: readonly s
5962

6063
export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentCommand {
6164
if (!prompt || prompt.includes('\0')) throw new Error('Claude prompt must be nonempty and contain no NUL.');
62-
assertPhasePolicy(policy);
65+
if (assertPhasePolicy(policy).vendor !== 'claude') throw new Error('Claude command requires a Claude invocation policy.');
6366
const writable = policy.worktree === 'read-write';
6467
const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep';
6568
return command(policy, ['claude', '--print', prompt, '--output-format', 'json', '--restricted', '--strict-mcp-config',
@@ -69,7 +72,7 @@ export function createClaudeCommand(policy: PhasePolicy, prompt: string): AgentC
6972
}
7073

7174
export function codexBaseArguments(policy: PhasePolicy): readonly string[] {
72-
assertPhasePolicy(policy);
75+
if (assertPhasePolicy(policy).vendor !== 'codex') throw new Error('Codex command requires a Codex invocation policy.');
7376
return Object.freeze(['codex', '--strict-config', '--config', 'web_search="disabled"',
7477
'--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']);
7578
}

‎test/agent-container.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ describe('real Docker agent isolation', () => {
182182
imageId: AGENT_IMAGE })).toThrow('immutable built image ID');
183183
chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555);
184184
expect(() => profile(data, 'planning', 'noop')).toThrow('only one bounded');
185-
});
185+
}, 60_000);
186186

187187
it('rejects unexpected host mounts and unbounded task volumes after Docker resolves them', () => {
188188
const data = fixture(), valid = profile(data, 'planning', 'noop');

‎test/agent-network.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe('vendor-only egress', () => {
5151

5252
it('rejects a copied network capability', () => {
5353
expect(() => createVendorNetwork(invocation, imageId, 0)).toThrow('positive integer');
54+
expect(() => assertVendorNetwork(network, invocation, undefined, 0)).toThrow('positive integer');
5455
expect(() => removeVendorNetwork({ ...network })).toThrow('trusted network builder');
5556
const otherInvocation = captureInvocation({ ...invocation, attemptId: 'other-network-probe',
5657
deadline: Date.now() + 60_000 });
@@ -73,4 +74,23 @@ describe('vendor-only egress', () => {
7374
removeVendorNetwork(other);
7475
}
7576
}, 60_000);
77+
78+
it('rejects a proxy replaced with a host namespace before launch', () => {
79+
const replacementInvocation = captureInvocation({ ...invocation, attemptId: 'mutated-proxy-probe',
80+
deadline: Date.now() + 60_000 });
81+
const replacement = createVendorNetwork(replacementInvocation, imageId);
82+
const inspected = JSON.parse(docker('container', 'inspect', replacement.proxyContainer))[0] as
83+
{ Config: { Labels: Record<string, string> } };
84+
const allocation = inspected.Config.Labels['io.codeboost.egress'];
85+
try {
86+
docker('rm', '--force', replacement.proxyContainer);
87+
docker('run', '--detach', '--name', replacement.proxyContainer, '--read-only', '--user', '10001:10001',
88+
'--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m',
89+
'--cpus=.25', '--pid=host', '--network', replacement.name, '--network-alias', 'codeboost-proxy',
90+
'--label', `io.codeboost.egress=${allocation}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS.claude.join(',')}`,
91+
'--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs');
92+
docker('network', 'connect', 'bridge', replacement.proxyContainer);
93+
expect(() => assertVendorNetwork(replacement, replacementInvocation)).toThrow('network or proxy changed');
94+
} finally { removeVendorNetwork(replacement); }
95+
}, 60_000);
7696
});

‎test/agent-policy.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { captureInvocation, type InvocationInput, type Phase } from '../agents/c
33
import { assertAgentCommand, assertAgentTool, codexBaseArguments, createClaudeCommand, createCodexCommand,
44
createPhasePolicy, dispatchApprovedCommand } from '../agents/policy.ts';
55

6-
const request = (phase: Phase): InvocationInput => captureInvocation({
6+
const request = (phase: Phase, vendor: 'claude' | 'codex' = 'claude'): InvocationInput => captureInvocation({
77
clone: { id: 'clone-1', taskId: 'task-1', directory: '/tmp/task', head: 'a'.repeat(40) },
8-
vendor: 'claude', phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['npm', 'test']],
8+
vendor, phase, approvedArgv: ['planning', 'questions'].includes(phase) ? [] : [['npm', 'test']],
99
deadline: 2000, attemptId: `attempt-${phase}`,
1010
context: { snapshotId: 's', planId: 'p', planRevision: 1, assignmentId: 'a', referencedCodeHash: 'c', stateVersion: 1 },
1111
}, 1000);
@@ -45,10 +45,14 @@ describe('agent phase policy', () => {
4545
expect(claude).toContain('Read,Glob,Grep');
4646
expect(claude).toContain('Bash,WebFetch,WebSearch,NotebookEdit');
4747
expect(claude).not.toContain('Edit');
48-
expect(codexBaseArguments(readonly)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"',
48+
const codexPolicy = createPhasePolicy(request('planning', 'codex'));
49+
expect(codexBaseArguments(codexPolicy)).toEqual(['codex', '--strict-config', '--config', 'web_search="disabled"',
4950
'--config', 'mcp_servers={}', '--config', 'features.shell_tool=false', '--ask-for-approval', 'never']);
50-
const codex = createCodexCommand(readonly, 'Inspect the schema.');
51+
const codex = createCodexCommand(codexPolicy, 'Inspect the schema.');
5152
expect(codex.argv).toContain('features.shell_tool=false');
52-
expect(() => assertAgentCommand({ argv: codex.argv }, readonly)).toThrow('not generated');
53+
expect(() => assertAgentCommand({ argv: codex.argv }, codexPolicy)).toThrow('not generated');
54+
expect(() => assertAgentCommand(codex, codexPolicy, 'claude')).toThrow('vendor');
55+
expect(() => createClaudeCommand(codexPolicy, 'Wrong vendor.')).toThrow('Claude invocation');
56+
expect(() => createCodexCommand(readonly, 'Wrong vendor.')).toThrow('Codex invocation');
5357
});
5458
});

0 commit comments

Comments
 (0)