Skip to content

Commit 124800b

Browse files
committed
Harden D2 container validation and Linux setup
1 parent 17d71d0 commit 124800b

4 files changed

Lines changed: 95 additions & 55 deletions

File tree

‎agents/container/Dockerfile‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \
88
"@anthropic-ai/claude-code@${CLAUDE_VERSION}" \
99
&& npm cache clean --force \
1010
&& useradd --uid 10001 --user-group --no-create-home --shell /usr/sbin/nologin codeboost \
11-
&& install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost
11+
&& install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost \
12+
&& install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git
1213

1314
COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe
1415

‎agents/container/profile.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createHash } from 'node:crypto';
22
import { lstatSync, readdirSync, realpathSync } from 'node:fs';
33
import type { InvocationInput, Phase } from '../contract.ts';
4-
import { AGENT_IMAGE } from './image.ts';
54

65
export interface TaskFilesystems {
76
readonly keeper: string;
@@ -18,7 +17,6 @@ export interface ContainerProfile {
1817
readonly expectedImage: string;
1918
readonly phase: Phase;
2019
readonly vendor: 'claude' | 'codex';
21-
readonly networkMode: 'none' | 'bridge';
2220
readonly filesystems: TaskFilesystems;
2321
readonly inputDirectory: string;
2422
readonly codexAuthFile?: string;
@@ -29,6 +27,7 @@ export interface ProfileOptions {
2927
readonly filesystems: TaskFilesystems;
3028
readonly inputDirectory: string;
3129
readonly command: readonly string[];
30+
readonly imageId: string;
3231
readonly codexAuthFile?: string;
3332
readonly claudeToken?: string;
3433
}
@@ -48,6 +47,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
4847
const { invocation, filesystems } = options;
4948
if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0')))
5049
throw new Error('Container command must be a complete literal argv array.');
50+
if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId))
51+
throw new Error('Container profile requires the immutable built image ID.');
5152
const inputStat = options.inputDirectory ? lstatSync(options.inputDirectory) : undefined;
5253
if (!inputStat?.isDirectory() || (inputStat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be container-readable.');
5354
const inputDirectory = mountSource(realpathSync(options.inputDirectory), 'Schema input');
@@ -73,10 +74,9 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
7374
}
7475
const name = `codeboost-agent-${safeName(invocation.attemptId)}`;
7576
const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase);
76-
const networkMode = 'none';
7777
const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL',
7878
'--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1',
79-
`--network=${networkMode}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`,
79+
'--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`,
8080
'--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache',
8181
'--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`,
8282
'--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`,
@@ -91,10 +91,10 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
9191
'--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700',
9292
'--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true }));
9393
} else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN');
94-
args.push(AGENT_IMAGE, ...options.command);
94+
args.push(options.imageId, ...options.command);
9595
const capturedFilesystems = Object.freeze({ ...filesystems });
96-
return Object.freeze({ name, args: Object.freeze(args), expectedImage: AGENT_IMAGE,
97-
phase: invocation.phase, vendor: invocation.vendor, networkMode,
96+
return Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId,
97+
phase: invocation.phase, vendor: invocation.vendor,
9898
filesystems: capturedFilesystems, inputDirectory, codexAuthFile,
9999
command: Object.freeze([...options.command]) });
100100
}

‎agents/container/run.ts‎

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
22
import { randomUUID } from 'node:crypto';
33
import { lstatSync, realpathSync } from 'node:fs';
44
import type { ContainerProfile, TaskFilesystems } from './profile.ts';
5-
import { AGENT_IMAGE, BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts';
5+
import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts';
66

77
const dockerEnvironment = (secrets: Readonly<Record<string, string>> = {}) => ({
88
PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, ...secrets,
@@ -44,8 +44,9 @@ export interface TaskStorageLimits {
4444

4545
/** Allocate bounded, engine-owned task filesystems and keep them mounted. */
4646
export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskStorageLimits,
47-
timeoutMs = 60_000): TaskFilesystems {
47+
imageId: string, timeoutMs = 60_000): TaskFilesystems {
4848
for (const [name, value] of Object.entries(limits)) validLimit(value, name);
49+
if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.');
4950
const remaining = createDeadline(timeoutMs);
5051
const staging = realpathSync(stagingDirectory);
5152
if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.');
@@ -62,29 +63,25 @@ export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskSto
6263
}
6364
const seed = [
6465
'set -eu',
65-
'cp -a /run/codeboost-staging/. /work/',
66-
'cp -a /work/.git/. /metadata/',
66+
'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/',
67+
'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/',
6768
'rm -rf /work/.git',
6869
'mkdir /work/.git',
69-
'touch /metadata/.codeboost-ready',
70-
'exec sleep infinity',
70+
'chown -R 10001:10001 /work /metadata',
7171
].join('; ');
7272
docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none',
7373
'--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25',
74+
'--mount', `type=volume,source=${workVolume},target=/work`,
75+
'--mount', `type=volume,source=${metadataVolume},target=/metadata`,
76+
'--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sleep', imageId, 'infinity'],
77+
{ timeoutMs: remaining() });
78+
docker(['run', '--rm', '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL',
79+
'--cap-add=CHOWN', '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32',
80+
'--memory=128m', '--cpus=.25',
7481
'--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`,
7582
'--mount', `type=volume,source=${workVolume},target=/work`,
7683
'--mount', `type=volume,source=${metadataVolume},target=/metadata`,
77-
'--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sh', AGENT_IMAGE, '-c', seed], { timeoutMs: remaining() });
78-
while (true) {
79-
const ready = spawnSync('docker', ['exec', keeper, 'sh', '-c',
80-
'test -f /metadata/.codeboost-ready && rm /metadata/.codeboost-ready'], {
81-
timeout: remaining(), env: dockerEnvironment(),
82-
stdio: ['ignore', 'ignore', 'ignore'],
83-
});
84-
if (ready.status === 0) break;
85-
if (ready.error) throw new Error('Timed out preparing bounded task filesystems.');
86-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
87-
}
84+
'--entrypoint', 'sh', imageId, '-c', seed], { timeoutMs: remaining() });
8885
return Object.freeze({ keeper, workVolume, metadataVolume, ...limits });
8986
} catch (error) {
9087
spawnSync('docker', ['rm', '--force', keeper], { env: dockerEnvironment(), stdio: 'ignore' });
@@ -113,7 +110,8 @@ export function validateContainer(container: string, profile: ContainerProfile,
113110
{ Id?: string; Config?: { User?: string; Entrypoint?: string[]; Labels?: Record<string, string> } } | undefined;
114111
const imageId = image?.Id, labels = image?.Config?.Labels ?? {};
115112
const host = inspect.HostConfig;
116-
if (!imageId || inspect.Image !== imageId || inspect.Config.Image !== profile.expectedImage
113+
if (!imageId || imageId !== profile.expectedImage || inspect.Image !== profile.expectedImage
114+
|| inspect.Config.Image !== profile.expectedImage
117115
|| image?.Config?.User !== '10001:10001'
118116
|| JSON.stringify(image.Config?.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe'])
119117
|| labels['org.opencontainers.image.base.name'] !== BASE_IMAGE
@@ -127,15 +125,22 @@ export function validateContainer(container: string, profile: ContainerProfile,
127125
|| !host.ReadonlyRootfs || host.Privileged
128126
|| !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL')
129127
|| !host.SecurityOpt?.some(value => value.startsWith('no-new-privileges'))
130-
|| host.NetworkMode !== profile.networkMode || host.PidMode === 'host' || host.IpcMode === 'host'
128+
|| host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private'
131129
|| (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128
132130
|| host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000)
133131
throw new Error('Container daemon configuration is missing required lockdown.');
134132
const tmpfs = host.Tmpfs ?? {};
135-
for (const path of ['/tmp', '/home/codeboost']) if (!tmpfs[path]?.includes('size='))
136-
throw new Error(`Container is missing bounded tmpfs ${path}.`);
137-
if (profile.vendor === 'codex' && !tmpfs['/run/codeboost-auth/codex']?.includes('size='))
138-
throw new Error('Codex state directory must be bounded tmpfs.');
133+
const expectedTmpfs = new Map([
134+
['/tmp', ['rw', 'nosuid', 'nodev', 'size=33554432', 'nr_inodes=4096', 'mode=1777']],
135+
['/home/codeboost', ['rw', 'nosuid', 'nodev', 'size=1048576', 'nr_inodes=128', 'uid=10001', 'gid=10001', 'mode=0700']],
136+
...(profile.vendor === 'codex' ? [['/run/codeboost-auth/codex',
137+
['rw', 'nosuid', 'nodev', 'size=4194304', 'nr_inodes=256', 'uid=10001', 'gid=10001', 'mode=0700']] as const] : []),
138+
]);
139+
if (Object.keys(tmpfs).length !== expectedTmpfs.size) throw new Error('Container tmpfs mount set changed.');
140+
for (const [path, expected] of expectedTmpfs) {
141+
const actual = new Set((tmpfs[path] ?? '').split(','));
142+
if (expected.some(option => !actual.has(option))) throw new Error(`Container tmpfs ${path} is missing required options.`);
143+
}
139144
const mounts = new Map(inspect.Mounts.map(item => [item.Destination, item]));
140145
const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input',
141146
...(profile.vendor === 'codex' ? ['/run/codeboost-auth/codex/auth.json'] : [])]);
@@ -164,15 +169,17 @@ export function validateContainer(container: string, profile: ContainerProfile,
164169
|| volume.Labels?.['io.codeboost.task-storage'] !== expected[0]
165170
|| !optionString.split(',').includes(`size=${expected[1]}`)
166171
|| !optionString.split(',').includes(`nr_inodes=${expected[2]}`)
167-
|| !optionString.split(',').includes('nosuid') || !optionString.split(',').includes('nodev'))
172+
|| !optionString.split(',').includes('uid=10001') || !optionString.split(',').includes('gid=10001')
173+
|| !optionString.split(',').includes('mode=0755') || !optionString.split(',').includes('nosuid')
174+
|| !optionString.split(',').includes('nodev'))
168175
throw new Error('Task volume does not match its bounded tmpfs allocation.');
169176
}
170177
const keeper = JSON.parse(docker(['container', 'inspect', profile.filesystems.keeper], { timeoutMs: remaining() }))[0] as
171178
{ State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record<string, string> };
172179
HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; NetworkMode?: string; CapDrop?: string[] | null;
173180
SecurityOpt?: string[] | null }; Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined;
174181
const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item]));
175-
if (!keeper?.State?.Running || keeper.Config?.Image !== AGENT_IMAGE || keeper.Config?.User !== '10001:10001'
182+
if (!keeper?.State?.Running || keeper.Config?.Image !== profile.expectedImage || keeper.Config?.User !== '10001:10001'
176183
|| keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs
177184
|| keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none'
178185
|| !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL')

0 commit comments

Comments
 (0)