Skip to content

Commit fb6577c

Browse files
mchwangclaude
andcommitted
Enforce builtin seccomp and pin clone directory identity
- Request Docker's builtin seccomp profile for the agent, keeper and seeder containers, since the daemon default can be unconfined (Docker Desktop reports profile=unconfined). Validation requires exactly no-new-privileges plus seccomp=builtin, and the startup probe refuses to exec unless /proc/self/status reports an active filter. - Record the clone directory and .git identities at creation and re-verify them before and after seeding, so a replaced staging path is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c61b3b5 commit fb6577c

6 files changed

Lines changed: 61 additions & 14 deletions

File tree

‎agents/container/probe.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ for field in CapInh CapPrm CapEff CapBnd CapAmb; do
2222
|| fail 'all capability sets must be empty'
2323
done
2424
[ "$(awk '/^NoNewPrivs:/ { print $2 }' /proc/self/status)" = '1' ] || fail 'no-new-privileges must be enabled'
25+
[ "$(awk '/^Seccomp:/ { print $2 }' /proc/self/status)" = '2' ] || fail 'a seccomp syscall filter must be enforced'
2526
require_option / ro
2627

2728
[ "${HOME:-}" = '/home/codeboost' ] || fail 'HOME must be the isolated home directory'

‎agents/container/profile.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil
178178
const name = `codeboost-agent-${safeName(invocation.attemptId)}`, ownershipId = randomUUID();
179179
const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase);
180180
const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL',
181-
'--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--memory-swap=512m',
181+
'--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=128', '--memory=512m', '--memory-swap=512m',
182182
'--cpus=1', '--shm-size=16m', '--ipc=private', '--cgroupns=private',
183183
'--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`,
184184
'--label', `io.codeboost.invocation=${ownershipId}`,

‎agents/container/run.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ const createDeadline = (timeoutMs: number) => {
3131
return value;
3232
};
3333
};
34-
const exactNoNewPrivileges = (options: string[] | null | undefined) => options?.length === 1
35-
&& (options[0] === 'no-new-privileges' || options[0] === 'no-new-privileges:true');
34+
// Only no-new-privileges plus Docker's builtin seccomp profile; the daemon default may be unconfined.
35+
const exactSecurityOptions = (options: string[] | null | undefined) => options?.length === 2
36+
&& options.some(option => option === 'no-new-privileges' || option === 'no-new-privileges:true')
37+
&& options.includes('seccomp=builtin');
3638
export const hasExactOptions = (value: string | undefined, expected: readonly string[]) => {
3739
const parts = value?.split(',') ?? [];
3840
return parts.length === expected.length && new Set(parts).size === parts.length
@@ -128,7 +130,7 @@ export function validateContainer(container: string, profile: ContainerProfile,
128130
|| inspect.Config.Labels?.['io.codeboost.invocation'] !== profile.ownershipId
129131
|| !host.ReadonlyRootfs || host.Privileged
130132
|| !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0
131-
|| !exactNoNewPrivileges(host.SecurityOpt)
133+
|| !exactSecurityOptions(host.SecurityOpt)
132134
|| host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private'
133135
|| host.UTSMode !== '' || host.UsernsMode !== '' || host.CgroupnsMode !== 'private'
134136
|| (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128
@@ -199,7 +201,7 @@ export function validateContainer(container: string, profile: ContainerProfile,
199201
|| keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none'
200202
|| !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL')
201203
|| (keeper.HostConfig.CapAdd?.length ?? 0) !== 0
202-
|| !exactNoNewPrivileges(keeper.HostConfig.SecurityOpt)
204+
|| !exactSecurityOptions(keeper.HostConfig.SecurityOpt)
203205
|| keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume
204206
|| keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume)
205207
throw new Error('Task filesystems must remain owned by their trusted keeper.');

‎agents/container/storage.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,7 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi
102102
for (const [name, value] of Object.entries(limits)) validLimit(value, name);
103103
if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.');
104104
assertBuiltAgentImage(imageId);
105-
assertTaskClone(clone);
106-
const remaining = createDeadline(timeoutMs), staging = realpathSync(clone.directory);
105+
const staging = assertTaskClone(clone), remaining = createDeadline(timeoutMs);
107106
if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.');
108107
if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.');
109108
const allocationId = randomUUID();
@@ -125,16 +124,18 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi
125124
'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/.git/. /metadata/', 'mkdir -p /work/.git',
126125
'chown -R 10001:10001 /work /metadata'].join('; ');
127126
docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none',
128-
'--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25',
127+
'--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=32', '--memory=128m', '--cpus=.25',
129128
'--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`,
130129
'--label', 'io.codeboost.task-storage=keeper', '--label', `io.codeboost.allocation=${allocationId}`,
131130
'--entrypoint', 'sleep', imageId, 'infinity'], remaining());
132131
docker(['run', '--rm', '--name', seeder, '--label', `io.codeboost.allocation=${allocationId}`,
133132
'--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN',
134-
'--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32',
133+
'--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=32',
135134
'--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`,
136135
'--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`,
137136
'--entrypoint', 'sh', imageId, '-c', seed], remaining());
137+
// Reject a staging directory swapped while the seeder was reading it.
138+
assertTaskClone(clone);
138139
remaining();
139140
const filesystems = Object.freeze({ keeper, workVolume, metadataVolume, ...limits });
140141
allocations.set(filesystems, Object.freeze({ allocationId,

‎git/clone.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,25 @@ import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node:
44
import { isAbsolute, join, relative, resolve } from 'node:path';
55
import type { TaskClone } from '../agents/contract.ts';
66

7-
const trustedClones = new WeakSet<TaskClone>();
7+
interface DirectoryIdentity { readonly dev: number; readonly ino: number }
8+
interface CloneIdentity { readonly directory: string; readonly root: DirectoryIdentity; readonly metadata: DirectoryIdentity }
9+
const trustedClones = new WeakMap<TaskClone, CloneIdentity>();
10+
const directoryIdentity = (path: string): DirectoryIdentity | undefined => {
11+
const stat = lstatSync(path, { throwIfNoEntry: false });
12+
return stat?.isDirectory() && !stat.isSymbolicLink() ? { dev: stat.dev, ino: stat.ino } : undefined;
13+
};
14+
const sameDirectory = (actual: DirectoryIdentity | undefined, expected: DirectoryIdentity) =>
15+
actual?.dev === expected.dev && actual.ino === expected.ino;
816

9-
export function assertTaskClone(clone: TaskClone): void {
10-
if (!trustedClones.has(clone)) throw new Error('Task clone was not created by the trusted clone builder.');
17+
/** Authenticate a clone and prove its staging directory is still the one the builder created. */
18+
export function assertTaskClone(clone: TaskClone): string {
19+
const identity = trustedClones.get(clone);
20+
if (!identity) throw new Error('Task clone was not created by the trusted clone builder.');
21+
if (!sameDirectory(directoryIdentity(identity.directory), identity.root)
22+
|| !sameDirectory(directoryIdentity(join(identity.directory, '.git')), identity.metadata)
23+
|| realpathSync(identity.directory) !== identity.directory)
24+
throw new Error('Task clone directory was replaced after it was created.');
25+
return identity.directory;
1126
}
1227

1328
/**
@@ -86,7 +101,9 @@ export function createTaskClone(options: {
86101
run(directory, 'checkout', '--detach', options.head);
87102
if (run(directory, 'rev-parse', 'HEAD') !== options.head) throw new Error('Task head changed during clone.');
88103
const clone = Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head });
89-
trustedClones.add(clone);
104+
const root = directoryIdentity(directory), cloneMetadata = directoryIdentity(metadata);
105+
if (!root || !cloneMetadata) throw new Error('Task clone directory is not a real directory.');
106+
trustedClones.set(clone, Object.freeze({ directory: realpathSync(directory), root, metadata: cloneMetadata }));
90107
return clone;
91108
} catch (error) {
92109
rmSync(directory, { recursive: true, force: true });

‎test/agent-container.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { execFileSync, spawnSync } from 'node:child_process';
22
import { randomBytes, randomUUID } from 'node:crypto';
3-
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
3+
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
44
import { tmpdir } from 'node:os';
55
import { join } from 'node:path';
66
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -285,6 +285,32 @@ describe('real Docker agent isolation', () => {
285285
expect(existsSync(unsettled.codexAuthFile!)).toBe(true);
286286
}, 60_000);
287287

288+
it('refuses to seed a clone whose staging directory was replaced after creation', () => {
289+
const data = fixture();
290+
const clone = createTaskClone({ source: data.source, parent: join(data.root, 'staging'), taskId: 'task-2',
291+
head: git(data.source, 'rev-parse', 'HEAD') });
292+
renameSync(clone.directory, `${clone.directory}-original`);
293+
mkdirSync(clone.directory); git(clone.directory, 'init');
294+
expect(() => prepareTaskFilesystems(clone, {
295+
workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512,
296+
}, imageId)).toThrow('replaced after it was created');
297+
}, 60_000);
298+
299+
it('rejects a container that relies on the daemon default seccomp profile', () => {
300+
const data = fixture(), valid = profile(data, 'planning', ['true']);
301+
docker(...valid.args.filter(arg => arg !== '--security-opt=seccomp=builtin')); containers.add(valid.name);
302+
expect(() => validateContainer(valid.name, valid)).toThrow('lockdown');
303+
docker('rm', '--force', valid.name); containers.delete(valid.name);
304+
}, 60_000);
305+
306+
it('startup probe refuses to exec when seccomp filtering is disabled', () => {
307+
const result = spawnSync('docker', ['run', '--rm', '--read-only', '--user', '10001:10001', '--cap-drop=ALL',
308+
'--security-opt=no-new-privileges', '--security-opt=seccomp=unconfined', '--network=none', imageId, 'true'],
309+
{ encoding: 'utf8', timeout: 60_000 });
310+
expect(result.status).toBe(78);
311+
expect(result.stderr).toContain('seccomp syscall filter must be enforced');
312+
}, 60_000);
313+
288314
it('refuses a Codex auth path that is a link without resolving it', () => {
289315
const data = fixture(), link = join(data.root, 'auth-link.json');
290316
symlinkSync(data.fakeAuth, link);

0 commit comments

Comments
 (0)