Skip to content

Commit a02d9cc

Browse files
mchwangclaude
andauthored
D2: Add pinned restricted agent containers (#40)
* Add D2 pinned restricted agent containers * Harden D2 container validation and Linux setup * Close D2 profile and validation trust gaps * Require exact D2 capability and mount profiles * Trust D2 helper images and settle cleanup * Bind D2 resources to their owners * Close D2 namespace and cleanup gaps * Allow clone ownership regression to settle * Pin the D2 Codex state path * Seal D2 inputs and complete resource checks * Fix D2 seeding, tmpfs rounding, and namespace defaults - Seed Git metadata straight into its own volume so the work allocation never has to hold the worktree and history at once. - Compare tmpfs byte ceilings against the page-rounded limit in the probe. - Request private IPC and cgroup namespaces explicitly instead of relying on daemon defaults. - Treat null Ulimits/Blkio fields from Docker inspect as empty. - Run the isolation workflow on pushes to main, not the feature branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Close D2 auth swap, unowned cleanup, and restart gaps - Read Codex auth through one no-follow descriptor instead of lstat, realpath, then open, so the path cannot be swapped to another host file. - Retain staged credentials and fail when the agent container name is held by another invocation, instead of treating it as cleaned up. - Require Docker's default no-restart policy before start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reconcile killed container creates before disposing credentials A `docker create` client killed by its deadline may still land in the daemon. Cleanup now keeps checking for the container through a bounded settle window and retains staged credentials if absence cannot be proven. Also give the immutable-image test the same timeout as its neighbours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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> * Reconcile killed storage creates and honour the invocation deadline - Track which allocation step's Docker client was killed by its deadline and give that volume, keeper or seeder a bounded settle window during cleanup, removing it if it lands late. - Validate the task keeper's restart policy as well as the agent's. - Re-verify the registered clone's staging directory identity whenever task filesystems are bound to an invocation clone. - Clamp createValidatedContainer and runContainer to the captured invocation deadline and refuse to start once it has passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Authenticate captured invocations and cover CI test budgets - captureInvocation registers each frozen request it returns, and createContainerProfile rejects any invocation it did not capture, so a copied request with an edited phase cannot get a writable workspace. - Raise the agent isolation job timeout to cover the image build, teardown and per-test Docker budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin the runc runtime and capture each attempt once - Request --runtime=runc for the agent, keeper and seeder containers and require it during validation, so an alternate configured runtime cannot bypass the checked isolation settings. - captureInvocation refuses an attemptId it has already captured, so an existing request cannot be re-captured with an upgraded phase, deadline or command allowlist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1fdee6c commit a02d9cc

11 files changed

Lines changed: 1407 additions & 3 deletions

File tree

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

‎agents/container/Dockerfile‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
&& install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git
13+
14+
COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe
15+
16+
LABEL org.opencontainers.image.base.name="docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1" \
17+
io.codeboost.codex.version="0.153.4" \
18+
io.codeboost.claude.version="2.1.281" \
19+
io.codeboost.profile.version="1"
20+
21+
USER 10001:10001
22+
WORKDIR /work
23+
ENTRYPOINT ["/usr/local/bin/codeboost-container-probe"]

‎agents/container/image.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
const trustedImages = new Set<string>();
12+
13+
export function assertBuiltAgentImage(imageId: string): void {
14+
if (!trustedImages.has(imageId)) throw new Error('Agent image was not produced by the trusted validated builder.');
15+
}
16+
17+
export function buildAgentImage(timeoutMs = 10 * 60_000): string {
18+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Image build requires a finite positive deadline.');
19+
const deadline = performance.now() + timeoutMs;
20+
const remaining = () => {
21+
const value = Math.ceil(deadline - performance.now());
22+
if (value <= 0) throw new Error('Agent image build exceeded its overall deadline.');
23+
return value;
24+
};
25+
execFileSync('docker', ['build', '--pull=false', '--tag', AGENT_IMAGE, context], {
26+
timeout: remaining(), killSignal: 'SIGKILL', stdio: ['ignore', 'inherit', 'inherit'],
27+
});
28+
const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], {
29+
encoding: 'utf8', timeout: remaining(), stdio: ['ignore', 'pipe', 'pipe'],
30+
}))[0] as { Id?: string; Config?: { User?: string; Labels?: Record<string, string> } };
31+
remaining();
32+
const labels = inspect.Config?.Labels ?? {};
33+
if (!inspect.Id?.startsWith('sha256:') || inspect.Config?.User !== '10001:10001'
34+
|| labels['org.opencontainers.image.base.name'] !== BASE_IMAGE
35+
|| labels['io.codeboost.codex.version'] !== CODEX_VERSION
36+
|| labels['io.codeboost.claude.version'] !== CLAUDE_VERSION
37+
|| labels['io.codeboost.profile.version'] !== '1') throw new Error('Built agent image does not match the pinned profile.');
38+
trustedImages.add(inspect.Id);
39+
return inspect.Id;
40+
}

‎agents/container/probe.sh‎

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

0 commit comments

Comments
 (0)