Skip to content

Commit 17d71d0

Browse files
committed
Add D2 pinned restricted agent containers
1 parent d110d81 commit 17d71d0

7 files changed

Lines changed: 695 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Agent isolation
2+
on:
3+
push:
4+
branches: ['codex/agent-isolation-d2']
5+
pull_request:
6+
paths:
7+
- 'agents/**'
8+
- 'git/clone.ts'
9+
- 'test/agent-*.test.ts'
10+
- '.github/workflows/agent-isolation.yml'
11+
permissions:
12+
contents: read
13+
jobs:
14+
real-docker:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 15
17+
steps:
18+
- uses: actions/checkout@v4
19+
- uses: actions/setup-node@v4
20+
with:
21+
node-version: '26.7.0'
22+
cache: npm
23+
- run: npm ci --ignore-scripts
24+
- run: npm run typecheck
25+
- run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts

‎agents/container/Dockerfile‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
FROM node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1
2+
3+
ARG CODEX_VERSION=0.153.4
4+
ARG CLAUDE_VERSION=2.1.281
5+
6+
RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \
7+
"@openai/codex@${CODEX_VERSION}" \
8+
"@anthropic-ai/claude-code@${CLAUDE_VERSION}" \
9+
&& npm cache clean --force \
10+
&& useradd --uid 10001 --user-group --no-create-home --shell /usr/sbin/nologin codeboost \
11+
&& install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost
12+
13+
COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe
14+
15+
LABEL org.opencontainers.image.base.name="docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1" \
16+
io.codeboost.codex.version="0.153.4" \
17+
io.codeboost.claude.version="2.1.281" \
18+
io.codeboost.profile.version="1"
19+
20+
USER 10001:10001
21+
WORKDIR /work
22+
ENTRYPOINT ["/usr/local/bin/codeboost-container-probe"]

‎agents/container/image.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { dirname } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
export const AGENT_IMAGE = 'codeboost-agent:node26-codex0.153.4-claude2.1.281';
6+
export const BASE_IMAGE = 'docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1';
7+
export const CODEX_VERSION = '0.153.4';
8+
export const CLAUDE_VERSION = '2.1.281';
9+
10+
const context = dirname(fileURLToPath(import.meta.url));
11+
12+
export function buildAgentImage(timeoutMs = 10 * 60_000): string {
13+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Image build requires a finite positive deadline.');
14+
const deadline = performance.now() + timeoutMs;
15+
const remaining = () => {
16+
const value = Math.ceil(deadline - performance.now());
17+
if (value <= 0) throw new Error('Agent image build exceeded its overall deadline.');
18+
return value;
19+
};
20+
execFileSync('docker', ['build', '--pull=false', '--tag', AGENT_IMAGE, context], {
21+
timeout: remaining(), killSignal: 'SIGKILL', stdio: ['ignore', 'inherit', 'inherit'],
22+
});
23+
const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], {
24+
encoding: 'utf8', timeout: remaining(), stdio: ['ignore', 'pipe', 'pipe'],
25+
}))[0] as { Id?: string; Config?: { User?: string; Labels?: Record<string, string> } };
26+
const labels = inspect.Config?.Labels ?? {};
27+
if (!inspect.Id?.startsWith('sha256:') || inspect.Config?.User !== '10001:10001'
28+
|| labels['org.opencontainers.image.base.name'] !== BASE_IMAGE
29+
|| labels['io.codeboost.codex.version'] !== CODEX_VERSION
30+
|| labels['io.codeboost.claude.version'] !== CLAUDE_VERSION
31+
|| labels['io.codeboost.profile.version'] !== '1') throw new Error('Built agent image does not match the pinned profile.');
32+
return inspect.Id;
33+
}

‎agents/container/probe.sh‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
fail() { printf 'codeboost isolation probe: %s\n' "$1" >&2; exit 78; }
5+
mount_options() { findmnt --noheadings --output OPTIONS --target "$1" 2>/dev/null || fail "missing mount: $1"; }
6+
has_option() { printf '%s\n' "$1" | tr ',' '\n' | grep -Fxq "$2"; }
7+
require_option() { has_option "$(mount_options "$1")" "$2" || fail "$1 must be mounted $2"; }
8+
filesystem_bytes() { df -B1 --output=size "$1" | tail -n 1 | tr -d ' '; }
9+
filesystem_inodes() { df --output=itotal "$1" | tail -n 1 | tr -d ' '; }
10+
require_ceiling() {
11+
[ "$(filesystem_bytes "$1")" -le "$2" ] || fail "$1 exceeds its byte limit"
12+
[ "$(filesystem_inodes "$1")" -le "$3" ] || fail "$1 exceeds its inode limit"
13+
}
14+
15+
[ "$(id -u)" -ne 0 ] || fail 'agent process must not run as root'
16+
for field in CapInh CapPrm CapEff CapBnd CapAmb; do
17+
[ "$(awk -v name="$field:" '$1 == name { print $2 }' /proc/self/status)" = '0000000000000000' ] \
18+
|| fail 'all capability sets must be empty'
19+
done
20+
[ "$(awk '/^NoNewPrivs:/ { print $2 }' /proc/self/status)" = '1' ] || fail 'no-new-privileges must be enabled'
21+
require_option / ro
22+
23+
[ "${HOME:-}" = '/home/codeboost' ] || fail 'HOME must be the isolated home directory'
24+
[ "${CODEBOOST_PHASE:-}" != '' ] || fail 'phase is required'
25+
[ "${CODEBOOST_VENDOR:-}" = 'codex' ] || [ "${CODEBOOST_VENDOR:-}" = 'claude' ] || fail 'vendor is required'
26+
27+
[ "$(findmnt --noheadings --output FSTYPE --target /work)" = 'tmpfs' ] || fail '/work must use a bounded tmpfs task filesystem'
28+
[ "$(findmnt --noheadings --output FSTYPE --target /work/.git)" = 'tmpfs' ] || fail 'Git metadata must use a separate tmpfs filesystem'
29+
[ "$(stat -c %d /work)" != "$(stat -c %d /work/.git)" ] || fail 'Git metadata must not alias the work filesystem'
30+
require_ceiling /work "${CODEBOOST_WORK_BYTES:-0}" "${CODEBOOST_WORK_INODES:-0}"
31+
require_ceiling /work/.git "${CODEBOOST_METADATA_BYTES:-0}" "${CODEBOOST_METADATA_INODES:-0}"
32+
require_option /work/.git ro
33+
require_option /run/codeboost-input ro
34+
for path in /work /work/.git; do
35+
require_option "$path" nosuid
36+
require_option "$path" nodev
37+
done
38+
39+
case "$CODEBOOST_PHASE" in
40+
planning|questions|review) require_option /work ro ;;
41+
execute|fix) require_option /work rw ;;
42+
*) fail 'unsupported phase' ;;
43+
esac
44+
45+
for path in /tmp /home/codeboost; do
46+
[ "$(findmnt --noheadings --output FSTYPE --target "$path")" = 'tmpfs' ] || fail "$path must use tmpfs"
47+
require_option "$path" rw
48+
require_option "$path" nosuid
49+
require_option "$path" nodev
50+
done
51+
require_ceiling /tmp 33554432 4096
52+
require_ceiling /home/codeboost 1048576 128
53+
54+
[ -z "$(find /home/codeboost -mindepth 1 -maxdepth 1 -print -quit)" ] || fail 'HOME must begin empty'
55+
[ -z "$(find /tmp -mindepth 1 -maxdepth 1 -print -quit)" ] || fail '/tmp must begin empty'
56+
[ ! -e /var/run/docker.sock ] || fail 'Docker socket must not be mounted'
57+
58+
case "$CODEBOOST_VENDOR" in
59+
codex)
60+
[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || fail 'Claude credential must not accompany Codex'
61+
[ "${CODEX_HOME:-}" = '/run/codeboost-auth/codex' ] || fail 'CODEX_HOME must be isolated'
62+
[ -f "$CODEX_HOME/auth.json" ] || fail 'Codex auth file is missing'
63+
require_option "$CODEX_HOME" rw
64+
require_option "$CODEX_HOME" nosuid
65+
require_option "$CODEX_HOME" nodev
66+
require_option "$CODEX_HOME/auth.json" ro
67+
require_ceiling "$CODEX_HOME" 4194304 256
68+
;;
69+
claude)
70+
[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || fail 'Claude credential is missing'
71+
[ -z "${CODEX_HOME:-}" ] || fail 'Codex credential must not accompany Claude'
72+
;;
73+
esac
74+
75+
[ "$(git --version)" != '' ] || fail 'Git is unavailable'
76+
[ "$(codex --version)" = 'codex-cli 0.153.4' ] || fail 'unexpected Codex version'
77+
[ "$(claude --version | awk '{print $1}')" = '2.1.281' ] || fail 'unexpected Claude version'
78+
79+
exec "$@"

‎agents/container/profile.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { createHash } from 'node:crypto';
2+
import { lstatSync, readdirSync, realpathSync } from 'node:fs';
3+
import type { InvocationInput, Phase } from '../contract.ts';
4+
import { AGENT_IMAGE } from './image.ts';
5+
6+
export interface TaskFilesystems {
7+
readonly keeper: string;
8+
readonly workVolume: string;
9+
readonly metadataVolume: string;
10+
readonly workBytes: number;
11+
readonly workInodes: number;
12+
readonly metadataBytes: number;
13+
readonly metadataInodes: number;
14+
}
15+
export interface ContainerProfile {
16+
readonly name: string;
17+
readonly args: readonly string[];
18+
readonly expectedImage: string;
19+
readonly phase: Phase;
20+
readonly vendor: 'claude' | 'codex';
21+
readonly networkMode: 'none' | 'bridge';
22+
readonly filesystems: TaskFilesystems;
23+
readonly inputDirectory: string;
24+
readonly codexAuthFile?: string;
25+
readonly command: readonly string[];
26+
}
27+
export interface ProfileOptions {
28+
readonly invocation: InvocationInput;
29+
readonly filesystems: TaskFilesystems;
30+
readonly inputDirectory: string;
31+
readonly command: readonly string[];
32+
readonly codexAuthFile?: string;
33+
readonly claudeToken?: string;
34+
}
35+
36+
const safeName = (value: string) => {
37+
const prefix = value.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 24);
38+
return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
39+
};
40+
const mount = (parts: Record<string, string | boolean>) => Object.entries(parts)
41+
.map(([key, value]) => value === true ? key : `${key}=${value}`).join(',');
42+
const mountSource = (path: string, kind: string) => {
43+
if (!path || /[\0\n,]/.test(path)) throw new Error(`${kind} path cannot be represented as a Docker mount.`);
44+
return path;
45+
};
46+
47+
export function createContainerProfile(options: ProfileOptions): ContainerProfile {
48+
const { invocation, filesystems } = options;
49+
if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0')))
50+
throw new Error('Container command must be a complete literal argv array.');
51+
const inputStat = options.inputDirectory ? lstatSync(options.inputDirectory) : undefined;
52+
if (!inputStat?.isDirectory() || (inputStat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be container-readable.');
53+
const inputDirectory = mountSource(realpathSync(options.inputDirectory), 'Schema input');
54+
const entries = readdirSync(inputDirectory);
55+
const schema = entries.length === 1 && entries[0] === 'schema.json' ? lstatSync(`${inputDirectory}/schema.json`) : undefined;
56+
if (!schema?.isFile() || schema.isSymbolicLink() || schema.nlink !== 1 || schema.size > 1024 * 1024
57+
|| (schema.mode & 0o004) === 0)
58+
throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.');
59+
if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken))
60+
throw new Error('Codex requires only its auth file.');
61+
if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile))
62+
throw new Error('Claude requires only its OAuth token.');
63+
if (options.claudeToken?.includes('\0')) throw new Error('Claude OAuth token is malformed.');
64+
if (!/^codeboost-work-[0-9a-f-]+$/.test(filesystems.workVolume)
65+
|| !/^codeboost-metadata-[0-9a-f-]+$/.test(filesystems.metadataVolume)
66+
|| !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.');
67+
if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile())
68+
throw new Error('Codex auth must be a direct regular file, not a link.');
69+
const codexAuthFile = options.codexAuthFile ? mountSource(realpathSync(options.codexAuthFile), 'Codex auth') : undefined;
70+
if (codexAuthFile) {
71+
const auth = lstatSync(codexAuthFile);
72+
if (!auth.isFile() || auth.isSymbolicLink() || auth.size > 1024 * 1024) throw new Error('Codex auth must be a bounded regular file.');
73+
}
74+
const name = `codeboost-agent-${safeName(invocation.attemptId)}`;
75+
const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase);
76+
const networkMode = 'none';
77+
const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL',
78+
'--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1',
79+
`--network=${networkMode}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`,
80+
'--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache',
81+
'--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`,
82+
'--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`,
83+
'--env', 'XDG_CACHE_HOME=/tmp/xdg-cache',
84+
'--tmpfs', '/tmp:rw,nosuid,nodev,size=33554432,nr_inodes=4096,mode=1777',
85+
'--tmpfs', '/home/codeboost:rw,nosuid,nodev,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700',
86+
'--mount', mount({ type: 'volume', source: filesystems.workVolume, target: '/work', readonly: readOnlyWork }),
87+
'--mount', mount({ type: 'volume', source: filesystems.metadataVolume, target: '/work/.git', readonly: true }),
88+
'--mount', mount({ type: 'bind', source: inputDirectory, target: '/run/codeboost-input', readonly: true })];
89+
if (invocation.vendor === 'codex') {
90+
args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex',
91+
'--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700',
92+
'--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true }));
93+
} else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN');
94+
args.push(AGENT_IMAGE, ...options.command);
95+
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,
98+
filesystems: capturedFilesystems, inputDirectory, codexAuthFile,
99+
command: Object.freeze([...options.command]) });
100+
}

0 commit comments

Comments
 (0)