From 44807919af4c2170e5d9c48642964aecc2ed6178 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 08:33:31 -0700 Subject: [PATCH 01/17] Add D2 pinned restricted agent containers --- .github/workflows/agent-isolation.yml | 25 +++ agents/container/Dockerfile | 22 +++ agents/container/image.ts | 33 ++++ agents/container/probe.sh | 79 +++++++++ agents/container/profile.ts | 100 +++++++++++ agents/container/run.ts | 236 ++++++++++++++++++++++++++ test/agent-container.test.ts | 200 ++++++++++++++++++++++ 7 files changed, 695 insertions(+) create mode 100644 .github/workflows/agent-isolation.yml create mode 100644 agents/container/Dockerfile create mode 100644 agents/container/image.ts create mode 100644 agents/container/probe.sh create mode 100644 agents/container/profile.ts create mode 100644 agents/container/run.ts create mode 100644 test/agent-container.test.ts diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml new file mode 100644 index 0000000..b568c04 --- /dev/null +++ b/.github/workflows/agent-isolation.yml @@ -0,0 +1,25 @@ +name: Agent isolation +on: + push: + branches: ['codex/agent-isolation-d2'] + pull_request: + paths: + - 'agents/**' + - 'git/clone.ts' + - 'test/agent-*.test.ts' + - '.github/workflows/agent-isolation.yml' +permissions: + contents: read +jobs: + real-docker: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '26.7.0' + cache: npm + - run: npm ci --ignore-scripts + - run: npm run typecheck + - run: npx vitest run test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts diff --git a/agents/container/Dockerfile b/agents/container/Dockerfile new file mode 100644 index 0000000..c70ef94 --- /dev/null +++ b/agents/container/Dockerfile @@ -0,0 +1,22 @@ +FROM node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1 + +ARG CODEX_VERSION=0.153.4 +ARG CLAUDE_VERSION=2.1.281 + +RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \ + "@openai/codex@${CODEX_VERSION}" \ + "@anthropic-ai/claude-code@${CLAUDE_VERSION}" \ + && npm cache clean --force \ + && useradd --uid 10001 --user-group --no-create-home --shell /usr/sbin/nologin codeboost \ + && install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost + +COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe + +LABEL org.opencontainers.image.base.name="docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1" \ + io.codeboost.codex.version="0.153.4" \ + io.codeboost.claude.version="2.1.281" \ + io.codeboost.profile.version="1" + +USER 10001:10001 +WORKDIR /work +ENTRYPOINT ["/usr/local/bin/codeboost-container-probe"] diff --git a/agents/container/image.ts b/agents/container/image.ts new file mode 100644 index 0000000..15e8c7f --- /dev/null +++ b/agents/container/image.ts @@ -0,0 +1,33 @@ +import { execFileSync } from 'node:child_process'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const AGENT_IMAGE = 'codeboost-agent:node26-codex0.153.4-claude2.1.281'; +export const BASE_IMAGE = 'docker.io/library/node:26.7.0-bookworm@sha256:e929171d35b9df7773a3ec5b068e387fa109441dc90f91e6560af5d39b7e9bf1'; +export const CODEX_VERSION = '0.153.4'; +export const CLAUDE_VERSION = '2.1.281'; + +const context = dirname(fileURLToPath(import.meta.url)); + +export function buildAgentImage(timeoutMs = 10 * 60_000): string { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Image build requires a finite positive deadline.'); + const deadline = performance.now() + timeoutMs; + const remaining = () => { + const value = Math.ceil(deadline - performance.now()); + if (value <= 0) throw new Error('Agent image build exceeded its overall deadline.'); + return value; + }; + execFileSync('docker', ['build', '--pull=false', '--tag', AGENT_IMAGE, context], { + timeout: remaining(), killSignal: 'SIGKILL', stdio: ['ignore', 'inherit', 'inherit'], + }); + const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], { + encoding: 'utf8', timeout: remaining(), stdio: ['ignore', 'pipe', 'pipe'], + }))[0] as { Id?: string; Config?: { User?: string; Labels?: Record } }; + const labels = inspect.Config?.Labels ?? {}; + if (!inspect.Id?.startsWith('sha256:') || inspect.Config?.User !== '10001:10001' + || labels['org.opencontainers.image.base.name'] !== BASE_IMAGE + || labels['io.codeboost.codex.version'] !== CODEX_VERSION + || labels['io.codeboost.claude.version'] !== CLAUDE_VERSION + || labels['io.codeboost.profile.version'] !== '1') throw new Error('Built agent image does not match the pinned profile.'); + return inspect.Id; +} diff --git a/agents/container/probe.sh b/agents/container/probe.sh new file mode 100644 index 0000000..78ec706 --- /dev/null +++ b/agents/container/probe.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +fail() { printf 'codeboost isolation probe: %s\n' "$1" >&2; exit 78; } +mount_options() { findmnt --noheadings --output OPTIONS --target "$1" 2>/dev/null || fail "missing mount: $1"; } +has_option() { printf '%s\n' "$1" | tr ',' '\n' | grep -Fxq "$2"; } +require_option() { has_option "$(mount_options "$1")" "$2" || fail "$1 must be mounted $2"; } +filesystem_bytes() { df -B1 --output=size "$1" | tail -n 1 | tr -d ' '; } +filesystem_inodes() { df --output=itotal "$1" | tail -n 1 | tr -d ' '; } +require_ceiling() { + [ "$(filesystem_bytes "$1")" -le "$2" ] || fail "$1 exceeds its byte limit" + [ "$(filesystem_inodes "$1")" -le "$3" ] || fail "$1 exceeds its inode limit" +} + +[ "$(id -u)" -ne 0 ] || fail 'agent process must not run as root' +for field in CapInh CapPrm CapEff CapBnd CapAmb; do + [ "$(awk -v name="$field:" '$1 == name { print $2 }' /proc/self/status)" = '0000000000000000' ] \ + || fail 'all capability sets must be empty' +done +[ "$(awk '/^NoNewPrivs:/ { print $2 }' /proc/self/status)" = '1' ] || fail 'no-new-privileges must be enabled' +require_option / ro + +[ "${HOME:-}" = '/home/codeboost' ] || fail 'HOME must be the isolated home directory' +[ "${CODEBOOST_PHASE:-}" != '' ] || fail 'phase is required' +[ "${CODEBOOST_VENDOR:-}" = 'codex' ] || [ "${CODEBOOST_VENDOR:-}" = 'claude' ] || fail 'vendor is required' + +[ "$(findmnt --noheadings --output FSTYPE --target /work)" = 'tmpfs' ] || fail '/work must use a bounded tmpfs task filesystem' +[ "$(findmnt --noheadings --output FSTYPE --target /work/.git)" = 'tmpfs' ] || fail 'Git metadata must use a separate tmpfs filesystem' +[ "$(stat -c %d /work)" != "$(stat -c %d /work/.git)" ] || fail 'Git metadata must not alias the work filesystem' +require_ceiling /work "${CODEBOOST_WORK_BYTES:-0}" "${CODEBOOST_WORK_INODES:-0}" +require_ceiling /work/.git "${CODEBOOST_METADATA_BYTES:-0}" "${CODEBOOST_METADATA_INODES:-0}" +require_option /work/.git ro +require_option /run/codeboost-input ro +for path in /work /work/.git; do + require_option "$path" nosuid + require_option "$path" nodev +done + +case "$CODEBOOST_PHASE" in + planning|questions|review) require_option /work ro ;; + execute|fix) require_option /work rw ;; + *) fail 'unsupported phase' ;; +esac + +for path in /tmp /home/codeboost; do + [ "$(findmnt --noheadings --output FSTYPE --target "$path")" = 'tmpfs' ] || fail "$path must use tmpfs" + require_option "$path" rw + require_option "$path" nosuid + require_option "$path" nodev +done +require_ceiling /tmp 33554432 4096 +require_ceiling /home/codeboost 1048576 128 + +[ -z "$(find /home/codeboost -mindepth 1 -maxdepth 1 -print -quit)" ] || fail 'HOME must begin empty' +[ -z "$(find /tmp -mindepth 1 -maxdepth 1 -print -quit)" ] || fail '/tmp must begin empty' +[ ! -e /var/run/docker.sock ] || fail 'Docker socket must not be mounted' + +case "$CODEBOOST_VENDOR" in + codex) + [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || fail 'Claude credential must not accompany Codex' + [ "${CODEX_HOME:-}" = '/run/codeboost-auth/codex' ] || fail 'CODEX_HOME must be isolated' + [ -f "$CODEX_HOME/auth.json" ] || fail 'Codex auth file is missing' + require_option "$CODEX_HOME" rw + require_option "$CODEX_HOME" nosuid + require_option "$CODEX_HOME" nodev + require_option "$CODEX_HOME/auth.json" ro + require_ceiling "$CODEX_HOME" 4194304 256 + ;; + claude) + [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || fail 'Claude credential is missing' + [ -z "${CODEX_HOME:-}" ] || fail 'Codex credential must not accompany Claude' + ;; +esac + +[ "$(git --version)" != '' ] || fail 'Git is unavailable' +[ "$(codex --version)" = 'codex-cli 0.153.4' ] || fail 'unexpected Codex version' +[ "$(claude --version | awk '{print $1}')" = '2.1.281' ] || fail 'unexpected Claude version' + +exec "$@" diff --git a/agents/container/profile.ts b/agents/container/profile.ts new file mode 100644 index 0000000..09b714f --- /dev/null +++ b/agents/container/profile.ts @@ -0,0 +1,100 @@ +import { createHash } from 'node:crypto'; +import { lstatSync, readdirSync, realpathSync } from 'node:fs'; +import type { InvocationInput, Phase } from '../contract.ts'; +import { AGENT_IMAGE } from './image.ts'; + +export interface TaskFilesystems { + readonly keeper: string; + readonly workVolume: string; + readonly metadataVolume: string; + readonly workBytes: number; + readonly workInodes: number; + readonly metadataBytes: number; + readonly metadataInodes: number; +} +export interface ContainerProfile { + readonly name: string; + readonly args: readonly string[]; + readonly expectedImage: string; + readonly phase: Phase; + readonly vendor: 'claude' | 'codex'; + readonly networkMode: 'none' | 'bridge'; + readonly filesystems: TaskFilesystems; + readonly inputDirectory: string; + readonly codexAuthFile?: string; + readonly command: readonly string[]; +} +export interface ProfileOptions { + readonly invocation: InvocationInput; + readonly filesystems: TaskFilesystems; + readonly inputDirectory: string; + readonly command: readonly string[]; + readonly codexAuthFile?: string; + readonly claudeToken?: string; +} + +const safeName = (value: string) => { + const prefix = value.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 24); + return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`; +}; +const mount = (parts: Record) => Object.entries(parts) + .map(([key, value]) => value === true ? key : `${key}=${value}`).join(','); +const mountSource = (path: string, kind: string) => { + if (!path || /[\0\n,]/.test(path)) throw new Error(`${kind} path cannot be represented as a Docker mount.`); + return path; +}; + +export function createContainerProfile(options: ProfileOptions): ContainerProfile { + const { invocation, filesystems } = options; + if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0'))) + throw new Error('Container command must be a complete literal argv array.'); + const inputStat = options.inputDirectory ? lstatSync(options.inputDirectory) : undefined; + if (!inputStat?.isDirectory() || (inputStat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be container-readable.'); + const inputDirectory = mountSource(realpathSync(options.inputDirectory), 'Schema input'); + const entries = readdirSync(inputDirectory); + const schema = entries.length === 1 && entries[0] === 'schema.json' ? lstatSync(`${inputDirectory}/schema.json`) : undefined; + if (!schema?.isFile() || schema.isSymbolicLink() || schema.nlink !== 1 || schema.size > 1024 * 1024 + || (schema.mode & 0o004) === 0) + throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.'); + if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) + throw new Error('Codex requires only its auth file.'); + if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile)) + throw new Error('Claude requires only its OAuth token.'); + if (options.claudeToken?.includes('\0')) throw new Error('Claude OAuth token is malformed.'); + if (!/^codeboost-work-[0-9a-f-]+$/.test(filesystems.workVolume) + || !/^codeboost-metadata-[0-9a-f-]+$/.test(filesystems.metadataVolume) + || !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.'); + if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) + throw new Error('Codex auth must be a direct regular file, not a link.'); + const codexAuthFile = options.codexAuthFile ? mountSource(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; + if (codexAuthFile) { + const auth = lstatSync(codexAuthFile); + if (!auth.isFile() || auth.isSymbolicLink() || auth.size > 1024 * 1024) throw new Error('Codex auth must be a bounded regular file.'); + } + const name = `codeboost-agent-${safeName(invocation.attemptId)}`; + const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); + const networkMode = 'none'; + const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', + '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1', + `--network=${networkMode}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', + '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, + '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, + '--env', 'XDG_CACHE_HOME=/tmp/xdg-cache', + '--tmpfs', '/tmp:rw,nosuid,nodev,size=33554432,nr_inodes=4096,mode=1777', + '--tmpfs', '/home/codeboost:rw,nosuid,nodev,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', + '--mount', mount({ type: 'volume', source: filesystems.workVolume, target: '/work', readonly: readOnlyWork }), + '--mount', mount({ type: 'volume', source: filesystems.metadataVolume, target: '/work/.git', readonly: true }), + '--mount', mount({ type: 'bind', source: inputDirectory, target: '/run/codeboost-input', readonly: true })]; + if (invocation.vendor === 'codex') { + args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', + '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', + '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); + } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); + args.push(AGENT_IMAGE, ...options.command); + const capturedFilesystems = Object.freeze({ ...filesystems }); + return Object.freeze({ name, args: Object.freeze(args), expectedImage: AGENT_IMAGE, + phase: invocation.phase, vendor: invocation.vendor, networkMode, + filesystems: capturedFilesystems, inputDirectory, codexAuthFile, + command: Object.freeze([...options.command]) }); +} diff --git a/agents/container/run.ts b/agents/container/run.ts new file mode 100644 index 0000000..09bf469 --- /dev/null +++ b/agents/container/run.ts @@ -0,0 +1,236 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstatSync, realpathSync } from 'node:fs'; +import type { ContainerProfile, TaskFilesystems } from './profile.ts'; +import { AGENT_IMAGE, BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; + +const dockerEnvironment = (secrets: Readonly> = {}) => ({ + PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, ...secrets, +}); +const validateSecrets = (profile: ContainerProfile, secrets: Readonly>) => { + const keys = Object.keys(secrets); + if (profile.vendor === 'codex' && keys.length) throw new Error('Codex profile must not receive environment credentials.'); + if (profile.vendor === 'claude' && (keys.length !== 1 || keys[0] !== 'CLAUDE_CODE_OAUTH_TOKEN' + || !secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.CLAUDE_CODE_OAUTH_TOKEN.includes('\0'))) + throw new Error('Claude profile requires only its OAuth environment credential.'); +}; +const docker = (args: readonly string[], options: { timeoutMs?: number; secrets?: Readonly> } = {}) => + execFileSync('docker', [...args], { encoding: 'utf8', timeout: options.timeoutMs ?? 30_000, + killSignal: 'SIGKILL', env: dockerEnvironment(options.secrets), stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +const validLimit = (value: number, name: string) => { + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`); +}; +const createDeadline = (timeoutMs: number) => { + validLimit(timeoutMs, 'timeoutMs'); + const deadline = performance.now() + timeoutMs; + return () => { + const value = Math.ceil(deadline - performance.now()); + if (value <= 0) throw new Error('Docker operation exceeded its overall deadline.'); + return value; + }; +}; +const resourceName = (kind: string) => `codeboost-${kind}-${randomUUID()}`; +const canonicalDockerBindSource = (source: string) => { + const desktopHostPath = source.startsWith('/host_mnt/') ? source.slice('/host_mnt'.length) : source; + try { return realpathSync(desktopHostPath); } catch { return source; } +}; + +export interface TaskStorageLimits { + readonly workBytes: number; + readonly workInodes: number; + readonly metadataBytes: number; + readonly metadataInodes: number; +} + +/** Allocate bounded, engine-owned task filesystems and keep them mounted. */ +export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskStorageLimits, + timeoutMs = 60_000): TaskFilesystems { + for (const [name, value] of Object.entries(limits)) validLimit(value, name); + const remaining = createDeadline(timeoutMs); + const staging = realpathSync(stagingDirectory); + if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); + if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); + const workVolume = resourceName('work'), metadataVolume = resourceName('metadata'), keeper = resourceName('keeper'); + const createdVolumes: string[] = []; + try { + for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes], + ['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) { + docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', + '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, + '--label', `io.codeboost.task-storage=${kind}`, name], { timeoutMs: remaining() }); + createdVolumes.push(name); + } + const seed = [ + 'set -eu', + 'cp -a /run/codeboost-staging/. /work/', + 'cp -a /work/.git/. /metadata/', + 'rm -rf /work/.git', + 'mkdir /work/.git', + 'touch /metadata/.codeboost-ready', + 'exec sleep infinity', + ].join('; '); + docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', + '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, + '--mount', `type=volume,source=${workVolume},target=/work`, + '--mount', `type=volume,source=${metadataVolume},target=/metadata`, + '--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sh', AGENT_IMAGE, '-c', seed], { timeoutMs: remaining() }); + while (true) { + const ready = spawnSync('docker', ['exec', keeper, 'sh', '-c', + 'test -f /metadata/.codeboost-ready && rm /metadata/.codeboost-ready'], { + timeout: remaining(), env: dockerEnvironment(), + stdio: ['ignore', 'ignore', 'ignore'], + }); + if (ready.status === 0) break; + if (ready.error) throw new Error('Timed out preparing bounded task filesystems.'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + return Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); + } catch (error) { + spawnSync('docker', ['rm', '--force', keeper], { env: dockerEnvironment(), stdio: 'ignore' }); + for (const volume of createdVolumes.reverse()) + spawnSync('docker', ['volume', 'rm', '--force', volume], { env: dockerEnvironment(), stdio: 'ignore' }); + throw error; + } +} + +type Inspect = { + Image: string; + Config: { Image: string; User: string; Env: string[]; Entrypoint: string[] | null; Cmd: string[] | null; WorkingDir: string }; + HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; + NetworkMode: string; PidMode: string; IpcMode: string; PidsLimit: number; Memory: number; NanoCpus: number; + Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; + Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; + Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; +}; + +/** Validate daemon-resolved configuration before starting an agent. */ +export function validateContainer(container: string, profile: ContainerProfile, timeoutMs = 30_000): void { + const remaining = createDeadline(timeoutMs); + const inspect = JSON.parse(docker(['container', 'inspect', container], { timeoutMs: remaining() }))[0] as Inspect | undefined; + if (!inspect) throw new Error('Docker did not return the created container.'); + const image = JSON.parse(docker(['image', 'inspect', profile.expectedImage], { timeoutMs: remaining() }))[0] as + { Id?: string; Config?: { User?: string; Entrypoint?: string[]; Labels?: Record } } | undefined; + const imageId = image?.Id, labels = image?.Config?.Labels ?? {}; + const host = inspect.HostConfig; + if (!imageId || inspect.Image !== imageId || inspect.Config.Image !== profile.expectedImage + || image?.Config?.User !== '10001:10001' + || JSON.stringify(image.Config?.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) + || labels['org.opencontainers.image.base.name'] !== BASE_IMAGE + || labels['io.codeboost.codex.version'] !== CODEX_VERSION + || labels['io.codeboost.claude.version'] !== CLAUDE_VERSION + || labels['io.codeboost.profile.version'] !== '1') + throw new Error('Container does not use the pinned agent image.'); + if (inspect.Config.User !== '10001:10001' || inspect.Config.WorkingDir !== '/work' + || JSON.stringify(inspect.Config.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) + || JSON.stringify(inspect.Config.Cmd) !== JSON.stringify(profile.command) + || !host.ReadonlyRootfs || host.Privileged + || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || !host.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) + || host.NetworkMode !== profile.networkMode || host.PidMode === 'host' || host.IpcMode === 'host' + || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 + || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) + throw new Error('Container daemon configuration is missing required lockdown.'); + const tmpfs = host.Tmpfs ?? {}; + for (const path of ['/tmp', '/home/codeboost']) if (!tmpfs[path]?.includes('size=')) + throw new Error(`Container is missing bounded tmpfs ${path}.`); + if (profile.vendor === 'codex' && !tmpfs['/run/codeboost-auth/codex']?.includes('size=')) + throw new Error('Codex state directory must be bounded tmpfs.'); + const mounts = new Map(inspect.Mounts.map(item => [item.Destination, item])); + const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', + ...(profile.vendor === 'codex' ? ['/run/codeboost-auth/codex/auth.json'] : [])]); + if (inspect.Mounts.some(item => !allowedMounts.has(item.Destination))) + throw new Error('Container includes an unexpected external mount.'); + const work = mounts.get('/work'), metadata = mounts.get('/work/.git'), input = mounts.get('/run/codeboost-input'); + if (work?.Type !== 'volume' || work.RW !== ['execute', 'fix'].includes(profile.phase) + || metadata?.Type !== 'volume' || metadata.RW || input?.Type !== 'bind' || input.RW) + throw new Error('Container mounts do not match the phase isolation profile.'); + const requestedMounts = new Map((host.Mounts ?? []).map(item => [item.Target, item])); + const requestedInput = requestedMounts.get('/run/codeboost-input'); + if (requestedInput?.Type !== 'bind' || canonicalDockerBindSource(requestedInput.Source) !== profile.inputDirectory + || !requestedInput.ReadOnly) throw new Error('Schema input mount identity changed.'); + if (work.Name !== profile.filesystems.workVolume || metadata.Name !== profile.filesystems.metadataVolume) + throw new Error('Container task volumes do not match their captured identity.'); + if (work.Source === metadata.Source) throw new Error('Worktree and Git metadata must use separate filesystems.'); + const volumes = JSON.parse(docker(['volume', 'inspect', work.Name!, metadata.Name!], { timeoutMs: remaining() })) as + Array<{ Name: string; Driver: string; Labels: Record | null; Options: Record | null }>; + const expectedVolumes = new Map([ + [work.Name!, ['work', String(profile.filesystems.workBytes), String(profile.filesystems.workInodes)]], + [metadata.Name!, ['metadata', String(profile.filesystems.metadataBytes), String(profile.filesystems.metadataInodes)]], + ]); + for (const volume of volumes) { + const expected = expectedVolumes.get(volume.Name), options = volume.Options ?? {}, optionString = options.o ?? ''; + if (!expected || volume.Driver !== 'local' || options.type !== 'tmpfs' || options.device !== 'tmpfs' + || volume.Labels?.['io.codeboost.task-storage'] !== expected[0] + || !optionString.split(',').includes(`size=${expected[1]}`) + || !optionString.split(',').includes(`nr_inodes=${expected[2]}`) + || !optionString.split(',').includes('nosuid') || !optionString.split(',').includes('nodev')) + throw new Error('Task volume does not match its bounded tmpfs allocation.'); + } + const keeper = JSON.parse(docker(['container', 'inspect', profile.filesystems.keeper], { timeoutMs: remaining() }))[0] as + { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record }; + HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; NetworkMode?: string; CapDrop?: string[] | null; + SecurityOpt?: string[] | null }; Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; + const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); + if (!keeper?.State?.Running || keeper.Config?.Image !== AGENT_IMAGE || keeper.Config?.User !== '10001:10001' + || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs + || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' + || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || !keeper.HostConfig.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) + || keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume + || keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume) + throw new Error('Task filesystems must remain owned by their trusted keeper.'); + const auth = mounts.get('/run/codeboost-auth/codex/auth.json'); + if (profile.vendor === 'codex' && (auth?.Type !== 'bind' || auth.RW)) throw new Error('Codex auth must be a read-only file mount.'); + const requestedAuth = requestedMounts.get('/run/codeboost-auth/codex/auth.json'); + if (profile.vendor === 'codex' && (requestedAuth?.Type !== 'bind' + || canonicalDockerBindSource(requestedAuth.Source) !== profile.codexAuthFile || !requestedAuth.ReadOnly)) + throw new Error('Codex auth mount identity changed.'); + if (profile.vendor === 'claude' && auth) throw new Error('Claude profile must not mount Codex auth.'); + if (inspect.Config.Env.some(value => value.indexOf('=') < 1)) throw new Error('Container environment is malformed.'); + const names = inspect.Config.Env.map(value => value.slice(0, value.indexOf('='))); + const environment = new Map(inspect.Config.Env.map(value => [value.slice(0, value.indexOf('=')), value.slice(value.indexOf('=') + 1)])); + const allowedEnvironment = new Set(['PATH', 'NODE_VERSION', 'YARN_VERSION', 'HOME', 'CODEBOOST_PHASE', 'CODEBOOST_VENDOR', + 'CODEBOOST_WORK_BYTES', 'CODEBOOST_WORK_INODES', 'CODEBOOST_METADATA_BYTES', 'CODEBOOST_METADATA_INODES', + 'npm_config_cache', 'XDG_CACHE_HOME', ...(profile.vendor === 'codex' ? ['CODEX_HOME'] : ['CLAUDE_CODE_OAUTH_TOKEN'])]); + if (new Set(names).size !== names.length || names.some(name => !allowedEnvironment.has(name))) + throw new Error('Container includes an unexpected environment variable.'); + if (environment.get('HOME') !== '/home/codeboost' || environment.get('CODEBOOST_PHASE') !== profile.phase + || environment.get('CODEBOOST_VENDOR') !== profile.vendor + || environment.get('CODEBOOST_WORK_BYTES') !== String(profile.filesystems.workBytes) + || environment.get('CODEBOOST_WORK_INODES') !== String(profile.filesystems.workInodes) + || environment.get('CODEBOOST_METADATA_BYTES') !== String(profile.filesystems.metadataBytes) + || environment.get('CODEBOOST_METADATA_INODES') !== String(profile.filesystems.metadataInodes)) + throw new Error('Container isolation environment changed.'); + if (profile.vendor === 'codex' && names.includes('CLAUDE_CODE_OAUTH_TOKEN')) throw new Error('Credential profiles must not be combined.'); + if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) + throw new Error('Credential profiles must not be combined.'); +} + +export function createValidatedContainer(profile: ContainerProfile, timeoutMs = 30_000, + secrets: Readonly> = {}): string { + const remaining = createDeadline(timeoutMs); + validateSecrets(profile, secrets); + try { + docker(profile.args, { timeoutMs: remaining(), secrets }); + validateContainer(profile.name, profile, remaining()); + return profile.name; + } catch (error) { + spawnSync('docker', ['rm', '--force', profile.name], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); + throw error; + } +} + +export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, + secrets: Readonly> = {}): string { + const remaining = createDeadline(timeoutMs); + const container = createValidatedContainer(profile, remaining(), secrets); + try { return docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); } + finally { spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); } +} + +export function removeTaskFilesystems(filesystems: TaskFilesystems): void { + spawnSync('docker', ['rm', '--force', filesystems.keeper], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); + for (const volume of [filesystems.metadataVolume, filesystems.workVolume]) + spawnSync('docker', ['volume', 'rm', '--force', volume], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); +} diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts new file mode 100644 index 0000000..f1476c0 --- /dev/null +++ b/test/agent-container.test.ts @@ -0,0 +1,200 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; +import { AGENT_IMAGE, buildAgentImage } from '../agents/container/image.ts'; +import { createContainerProfile } from '../agents/container/profile.ts'; +import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, + validateContainer } from '../agents/container/run.ts'; +import { createTaskClone } from '../git/clone.ts'; + +const roots: string[] = []; +const taskFilesystems: ReturnType[] = []; +const containers = new Set(); +const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], + { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +const docker = (...args: string[]) => execFileSync('docker', args, { + encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'agent-container-')); roots.push(root); + const source = join(root, 'source'), staging = join(root, 'staging'), input = join(root, 'input'); + mkdirSync(source); mkdirSync(staging); mkdirSync(input); + git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com'); + writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline'); + writeFileSync(join(input, 'schema.json'), '{"probe":"codeboost-schema-marker"}\n'); + chmodSync(join(input, 'schema.json'), 0o444); chmodSync(input, 0o555); + const clone = createTaskClone({ source, parent: staging, taskId: 'task-1', head: git(source, 'rev-parse', 'HEAD') }); + const filesystems = prepareTaskFilesystems(clone.directory, { + workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, + }); + taskFilesystems.push(filesystems); + const fakeAuth = join(root, 'auth.json'); writeFileSync(fakeAuth, '{}', { mode: 0o600 }); + return { root, source, input, clone, filesystems, fakeAuth }; +} + +function invocation(clone: ReturnType, phase: Phase, vendor: 'codex' | 'claude' = 'codex'): InvocationInput { + return captureInvocation({ clone, phase, vendor, approvedArgv: phase === 'planning' || phase === 'questions' ? [] : [['git', 'status']], + deadline: Date.now() + 60_000, attemptId: `${vendor}-${phase}-${Math.random().toString(16).slice(2)}`, + context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', + referencedCodeHash: 'code-1', stateVersion: 1 } }); +} + +function profile(data: ReturnType, phase: Phase, command: string[], options: { + vendor?: 'codex' | 'claude'; authProbe?: boolean; codexAuthFile?: string; claudeToken?: string; +} = {}) { + const vendor = options.vendor ?? 'codex'; + const base = createContainerProfile({ invocation: invocation(data.clone, phase, vendor), filesystems: data.filesystems, + inputDirectory: data.input, command, + codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, + claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); + if (!options.authProbe) return base; + // Test-only bridge access proves credentials work before D3 adds vendor-only egress. + return Object.freeze({ ...base, networkMode: 'bridge' as const, + args: Object.freeze(base.args.map(value => value === '--network=none' ? '--network=bridge' : value)) }); +} + +beforeAll(() => { buildAgentImage(); }, 10 * 60_000); +afterAll(() => { + for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); + for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); + for (const root of roots.reverse()) { + chmodSync(join(root, 'input'), 0o700); + rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } +}); + +describe('real Docker agent isolation', () => { + it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { + const data = fixture(); + process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; + try { + const output = runContainer(profile(data, 'planning', ['sh', '-c', [ + 'test "$(id -u)" = 10001', + 'test "$(git status --porcelain)" = ""', + 'test ! -e "$1"', + 'test -z "${HOST_SECRET_SENTINEL:-}"', + '! touch /work/forbidden', + '! touch /usr/bin/forbidden', + 'touch /tmp/allowed "$HOME/allowed"', + 'printf isolated', + ].join('; '), 'probe', data.source])); + expect(output).toBe('isolated'); + } finally { delete process.env.HOST_SECRET_SENTINEL; } + }, 60_000); + + it('persists execution changes while replacing HOME and scratch for each invocation', () => { + const data = fixture(); + expect(runContainer(profile(data, 'execute', ['sh', '-c', + 'printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first']))).toBe('first'); + const output = runContainer(profile(data, 'execute', ['sh', '-c', + 'test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain'])); + expect(output).toContain('?? generated.txt'); + }, 60_000); + + it('enforces work byte and inode ceilings before writes can exceed the allocation', () => { + const data = fixture(); + const output = runContainer(profile(data, 'execute', ['sh', '-c', [ + '! dd if=/dev/zero of=/work/overflow bs=1M count=32 2>/dev/null', + 'rm -f /work/overflow', + 'mkdir /work/many', + 'i=0; while touch "/work/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt 2000; done', + 'test "$i" -lt 2000', + 'rm -rf /work/many', + 'printf bounded', + ].join('; ')])); + expect(output).toBe('bounded'); + }, 60_000); + + it('keeps Git metadata read-only, on another filesystem, and mounted against replacement', () => { + const data = fixture(); + const output = runContainer(profile(data, 'execute', ['sh', '-c', [ + '! touch /work/.git/forbidden 2>/dev/null', + '! ln /work/.git/HEAD /work/metadata-link 2>/dev/null', + '! mv /work/.git /work/replaced 2>/dev/null', + 'git status --porcelain', + 'printf metadata-safe', + ].join('; ')])); + expect(output).toBe('metadata-safe'); + }, 60_000); + + it('refuses a container missing read-only root before its command runs', () => { + const data = fixture(); + const valid = profile(data, 'planning', ['sh', '-c', 'touch /tmp/command-ran']); + const args = valid.args.filter(value => value !== '--read-only'); + docker(...args); + containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + const result = spawnSync('docker', ['start', '--attach', valid.name], { encoding: 'utf8', timeout: 30_000 }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('must be mounted ro'); + containers.delete(valid.name); docker('rm', '--force', valid.name); + }, 60_000); + + it('rejects mixed credentials and unsupported command/profile inputs', () => { + const data = fixture(); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'codex'), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, + claudeToken: 'must-not-combine' })).toThrow('only'); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'] })).toThrow('OAuth'); + const claudeProfile = createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], claudeToken: 'serialization-sentinel' }); + expect(JSON.stringify(claudeProfile)).not.toContain('serialization-sentinel'); + expect(() => createValidatedContainer(claudeProfile)).toThrow('OAuth environment credential'); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + filesystems: data.filesystems, inputDirectory: data.input, command: [] })).toThrow('argv'); + chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); + expect(() => profile(data, 'planning', ['true'])).toThrow('only one bounded'); + }); + + it('rejects unexpected host mounts and unbounded task volumes after Docker resolves them', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const imageIndex = valid.args.indexOf(AGENT_IMAGE); + const extraMountArgs = [...valid.args.slice(0, imageIndex), '--mount', + 'type=bind,source=/tmp,target=/unexpected,readonly', ...valid.args.slice(imageIndex)]; + docker(...extraMountArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('unexpected external mount'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + + const rogue = `codeboost-work-${randomUUID()}`; docker('volume', 'create', rogue); + try { + const rogueArgs = valid.args.map(value => value.replace(data.filesystems.workVolume, rogue)); + const rogueProfile = Object.freeze({ ...valid, args: Object.freeze(rogueArgs), + filesystems: Object.freeze({ ...valid.filesystems, workVolume: rogue }) }); + docker(...rogueArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, rogueProfile)).toThrow('bounded tmpfs allocation'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + } finally { spawnSync('docker', ['volume', 'rm', '--force', rogue], { stdio: 'ignore' }); } + }, 60_000); + + if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { + it('runs the authenticated Codex startup path with isolated writable state', () => { + const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; + if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); + const output = runContainer(profile(data, 'planning', ['sh', '-c', [ + "codex exec --sandbox read-only --skip-git-repo-check --output-last-message /tmp/codex-output.txt 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.' >/tmp/codex-events.jsonl", + 'grep -Fx codeboost-schema-marker /tmp/codex-output.txt', + ].join('; ')], { authProbe: true, codexAuthFile: authFile }), 5 * 60_000); + expect(output).toBe('codeboost-schema-marker'); + }, 6 * 60_000); + + it('runs the authenticated Claude startup path with only its OAuth token', () => { + const data = fixture(), token = process.env.CLAUDE_CODE_OAUTH_TOKEN; + if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); + const output = runContainer(profile(data, 'planning', ['claude', '-p', + 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.', + '--output-format', 'json', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', + '--allowedTools', 'Read', '--add-dir', '/run/codeboost-input', + '--disallowedTools', 'WebFetch,WebSearch'], { vendor: 'claude', authProbe: true, claudeToken: token }), + 5 * 60_000, { CLAUDE_CODE_OAUTH_TOKEN: token }); + const envelope = JSON.parse(output) as { result?: string; is_error?: boolean }; + expect(envelope.is_error).not.toBe(true); + expect(envelope.result?.trim()).toBe('codeboost-schema-marker'); + }, 6 * 60_000); + } +}); From 3788d27f2e8b131a7e8698c5644f51376973003e Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 08:50:02 -0700 Subject: [PATCH 02/17] Harden D2 container validation and Linux setup --- agents/container/Dockerfile | 3 +- agents/container/profile.ts | 14 +++---- agents/container/run.ts | 57 +++++++++++++++------------ test/agent-container.test.ts | 76 +++++++++++++++++++++++++----------- 4 files changed, 95 insertions(+), 55 deletions(-) diff --git a/agents/container/Dockerfile b/agents/container/Dockerfile index c70ef94..6711760 100644 --- a/agents/container/Dockerfile +++ b/agents/container/Dockerfile @@ -8,7 +8,8 @@ RUN npm install --global --allow-scripts=@anthropic-ai/claude-code \ "@anthropic-ai/claude-code@${CLAUDE_VERSION}" \ && npm cache clean --force \ && useradd --uid 10001 --user-group --no-create-home --shell /usr/sbin/nologin codeboost \ - && install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost + && install --directory --owner=10001 --group=10001 --mode=0700 /home/codeboost \ + && install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git COPY --chmod=0555 probe.sh /usr/local/bin/codeboost-container-probe diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 09b714f..28fb510 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto'; import { lstatSync, readdirSync, realpathSync } from 'node:fs'; import type { InvocationInput, Phase } from '../contract.ts'; -import { AGENT_IMAGE } from './image.ts'; export interface TaskFilesystems { readonly keeper: string; @@ -18,7 +17,6 @@ export interface ContainerProfile { readonly expectedImage: string; readonly phase: Phase; readonly vendor: 'claude' | 'codex'; - readonly networkMode: 'none' | 'bridge'; readonly filesystems: TaskFilesystems; readonly inputDirectory: string; readonly codexAuthFile?: string; @@ -29,6 +27,7 @@ export interface ProfileOptions { readonly filesystems: TaskFilesystems; readonly inputDirectory: string; readonly command: readonly string[]; + readonly imageId: string; readonly codexAuthFile?: string; readonly claudeToken?: string; } @@ -48,6 +47,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const { invocation, filesystems } = options; if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0'))) throw new Error('Container command must be a complete literal argv array.'); + if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) + throw new Error('Container profile requires the immutable built image ID.'); const inputStat = options.inputDirectory ? lstatSync(options.inputDirectory) : undefined; if (!inputStat?.isDirectory() || (inputStat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be container-readable.'); const inputDirectory = mountSource(realpathSync(options.inputDirectory), 'Schema input'); @@ -73,10 +74,9 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil } const name = `codeboost-agent-${safeName(invocation.attemptId)}`; const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); - const networkMode = 'none'; const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1', - `--network=${networkMode}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, @@ -91,10 +91,10 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); - args.push(AGENT_IMAGE, ...options.command); + args.push(options.imageId, ...options.command); const capturedFilesystems = Object.freeze({ ...filesystems }); - return Object.freeze({ name, args: Object.freeze(args), expectedImage: AGENT_IMAGE, - phase: invocation.phase, vendor: invocation.vendor, networkMode, + return Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, + phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory, codexAuthFile, command: Object.freeze([...options.command]) }); } diff --git a/agents/container/run.ts b/agents/container/run.ts index 09bf469..92715bb 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; import type { ContainerProfile, TaskFilesystems } from './profile.ts'; -import { AGENT_IMAGE, BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; +import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, ...secrets, @@ -44,8 +44,9 @@ export interface TaskStorageLimits { /** Allocate bounded, engine-owned task filesystems and keep them mounted. */ export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskStorageLimits, - timeoutMs = 60_000): TaskFilesystems { + imageId: string, timeoutMs = 60_000): TaskFilesystems { for (const [name, value] of Object.entries(limits)) validLimit(value, name); + if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); const remaining = createDeadline(timeoutMs); const staging = realpathSync(stagingDirectory); 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 } const seed = [ 'set -eu', - 'cp -a /run/codeboost-staging/. /work/', - 'cp -a /work/.git/. /metadata/', + 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/', + 'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/', 'rm -rf /work/.git', 'mkdir /work/.git', - 'touch /metadata/.codeboost-ready', - 'exec sleep infinity', + 'chown -R 10001:10001 /work /metadata', ].join('; '); docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', + '--mount', `type=volume,source=${workVolume},target=/work`, + '--mount', `type=volume,source=${metadataVolume},target=/metadata`, + '--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sleep', imageId, 'infinity'], + { timeoutMs: remaining() }); + docker(['run', '--rm', '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', + '--cap-add=CHOWN', '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32', + '--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, - '--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sh', AGENT_IMAGE, '-c', seed], { timeoutMs: remaining() }); - while (true) { - const ready = spawnSync('docker', ['exec', keeper, 'sh', '-c', - 'test -f /metadata/.codeboost-ready && rm /metadata/.codeboost-ready'], { - timeout: remaining(), env: dockerEnvironment(), - stdio: ['ignore', 'ignore', 'ignore'], - }); - if (ready.status === 0) break; - if (ready.error) throw new Error('Timed out preparing bounded task filesystems.'); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); - } + '--entrypoint', 'sh', imageId, '-c', seed], { timeoutMs: remaining() }); return Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); } catch (error) { spawnSync('docker', ['rm', '--force', keeper], { env: dockerEnvironment(), stdio: 'ignore' }); @@ -113,7 +110,8 @@ export function validateContainer(container: string, profile: ContainerProfile, { Id?: string; Config?: { User?: string; Entrypoint?: string[]; Labels?: Record } } | undefined; const imageId = image?.Id, labels = image?.Config?.Labels ?? {}; const host = inspect.HostConfig; - if (!imageId || inspect.Image !== imageId || inspect.Config.Image !== profile.expectedImage + if (!imageId || imageId !== profile.expectedImage || inspect.Image !== profile.expectedImage + || inspect.Config.Image !== profile.expectedImage || image?.Config?.User !== '10001:10001' || JSON.stringify(image.Config?.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) || labels['org.opencontainers.image.base.name'] !== BASE_IMAGE @@ -127,15 +125,22 @@ export function validateContainer(container: string, profile: ContainerProfile, || !host.ReadonlyRootfs || host.Privileged || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || !host.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) - || host.NetworkMode !== profile.networkMode || host.PidMode === 'host' || host.IpcMode === 'host' + || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) throw new Error('Container daemon configuration is missing required lockdown.'); const tmpfs = host.Tmpfs ?? {}; - for (const path of ['/tmp', '/home/codeboost']) if (!tmpfs[path]?.includes('size=')) - throw new Error(`Container is missing bounded tmpfs ${path}.`); - if (profile.vendor === 'codex' && !tmpfs['/run/codeboost-auth/codex']?.includes('size=')) - throw new Error('Codex state directory must be bounded tmpfs.'); + const expectedTmpfs = new Map([ + ['/tmp', ['rw', 'nosuid', 'nodev', 'size=33554432', 'nr_inodes=4096', 'mode=1777']], + ['/home/codeboost', ['rw', 'nosuid', 'nodev', 'size=1048576', 'nr_inodes=128', 'uid=10001', 'gid=10001', 'mode=0700']], + ...(profile.vendor === 'codex' ? [['/run/codeboost-auth/codex', + ['rw', 'nosuid', 'nodev', 'size=4194304', 'nr_inodes=256', 'uid=10001', 'gid=10001', 'mode=0700']] as const] : []), + ]); + if (Object.keys(tmpfs).length !== expectedTmpfs.size) throw new Error('Container tmpfs mount set changed.'); + for (const [path, expected] of expectedTmpfs) { + const actual = new Set((tmpfs[path] ?? '').split(',')); + if (expected.some(option => !actual.has(option))) throw new Error(`Container tmpfs ${path} is missing required options.`); + } const mounts = new Map(inspect.Mounts.map(item => [item.Destination, item])); const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', ...(profile.vendor === 'codex' ? ['/run/codeboost-auth/codex/auth.json'] : [])]); @@ -164,7 +169,9 @@ export function validateContainer(container: string, profile: ContainerProfile, || volume.Labels?.['io.codeboost.task-storage'] !== expected[0] || !optionString.split(',').includes(`size=${expected[1]}`) || !optionString.split(',').includes(`nr_inodes=${expected[2]}`) - || !optionString.split(',').includes('nosuid') || !optionString.split(',').includes('nodev')) + || !optionString.split(',').includes('uid=10001') || !optionString.split(',').includes('gid=10001') + || !optionString.split(',').includes('mode=0755') || !optionString.split(',').includes('nosuid') + || !optionString.split(',').includes('nodev')) throw new Error('Task volume does not match its bounded tmpfs allocation.'); } const keeper = JSON.parse(docker(['container', 'inspect', profile.filesystems.keeper], { timeoutMs: remaining() }))[0] as @@ -172,7 +179,7 @@ export function validateContainer(container: string, profile: ContainerProfile, HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; NetworkMode?: string; CapDrop?: string[] | null; SecurityOpt?: string[] | null }; Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); - if (!keeper?.State?.Running || keeper.Config?.Image !== AGENT_IMAGE || keeper.Config?.User !== '10001:10001' + if (!keeper?.State?.Running || keeper.Config?.Image !== profile.expectedImage || keeper.Config?.User !== '10001:10001' || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index f1476c0..08e3926 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -14,6 +14,7 @@ import { createTaskClone } from '../git/clone.ts'; const roots: string[] = []; const taskFilesystems: ReturnType[] = []; const containers = new Set(); +let imageId = ''; const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const docker = (...args: string[]) => execFileSync('docker', args, { @@ -31,7 +32,7 @@ function fixture() { const clone = createTaskClone({ source, parent: staging, taskId: 'task-1', head: git(source, 'rev-parse', 'HEAD') }); const filesystems = prepareTaskFilesystems(clone.directory, { workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, - }); + }, imageId); taskFilesystems.push(filesystems); const fakeAuth = join(root, 'auth.json'); writeFileSync(fakeAuth, '{}', { mode: 0o600 }); return { root, source, input, clone, filesystems, fakeAuth }; @@ -50,15 +51,13 @@ function profile(data: ReturnType, phase: Phase, command: string const vendor = options.vendor ?? 'codex'; const base = createContainerProfile({ invocation: invocation(data.clone, phase, vendor), filesystems: data.filesystems, inputDirectory: data.input, command, + imageId, codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); - if (!options.authProbe) return base; - // Test-only bridge access proves credentials work before D3 adds vendor-only egress. - return Object.freeze({ ...base, networkMode: 'bridge' as const, - args: Object.freeze(base.args.map(value => value === '--network=none' ? '--network=bridge' : value)) }); + return base; } -beforeAll(() => { buildAgentImage(); }, 10 * 60_000); +beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000); afterAll(() => { for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); @@ -73,7 +72,7 @@ describe('real Docker agent isolation', () => { const data = fixture(); process.env.HOST_SECRET_SENTINEL = 'must-not-reach-container'; try { - const output = runContainer(profile(data, 'planning', ['sh', '-c', [ + const output = runContainer(profile(data, 'planning', ['sh', '-c', ['set -eu', 'test "$(id -u)" = 10001', 'test "$(git status --porcelain)" = ""', 'test ! -e "$1"', @@ -90,20 +89,21 @@ describe('real Docker agent isolation', () => { it('persists execution changes while replacing HOME and scratch for each invocation', () => { const data = fixture(); expect(runContainer(profile(data, 'execute', ['sh', '-c', - 'printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first']))).toBe('first'); + 'set -eu; printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first']))).toBe('first'); const output = runContainer(profile(data, 'execute', ['sh', '-c', - 'test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain'])); + 'set -eu; test -f /work/generated.txt; test ! -e /tmp/old; test ! -e "$HOME/old"; git status --porcelain'])); expect(output).toContain('?? generated.txt'); }, 60_000); it('enforces work byte and inode ceilings before writes can exceed the allocation', () => { const data = fixture(); - const output = runContainer(profile(data, 'execute', ['sh', '-c', [ + const output = runContainer(profile(data, 'execute', ['sh', '-c', ['set -eu', '! dd if=/dev/zero of=/work/overflow bs=1M count=32 2>/dev/null', 'rm -f /work/overflow', 'mkdir /work/many', 'i=0; while touch "/work/many/$i" 2>/dev/null; do i=$((i+1)); test "$i" -lt 2000; done', 'test "$i" -lt 2000', + 'test "$(find /work/many -type f | wc -l)" -eq "$i"', 'rm -rf /work/many', 'printf bounded', ].join('; ')])); @@ -112,7 +112,7 @@ describe('real Docker agent isolation', () => { it('keeps Git metadata read-only, on another filesystem, and mounted against replacement', () => { const data = fixture(); - const output = runContainer(profile(data, 'execute', ['sh', '-c', [ + const output = runContainer(profile(data, 'execute', ['sh', '-c', ['set -eu', '! touch /work/.git/forbidden 2>/dev/null', '! ln /work/.git/HEAD /work/metadata-link 2>/dev/null', '! mv /work/.git /work/replaced 2>/dev/null', @@ -131,7 +131,6 @@ describe('real Docker agent isolation', () => { expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); const result = spawnSync('docker', ['start', '--attach', valid.name], { encoding: 'utf8', timeout: 30_000 }); expect(result.status).not.toBe(0); - expect(result.stderr).toContain('must be mounted ro'); containers.delete(valid.name); docker('rm', '--force', valid.name); }, 60_000); @@ -139,22 +138,26 @@ describe('real Docker agent isolation', () => { const data = fixture(); expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'codex'), filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, - claudeToken: 'must-not-combine' })).toThrow('only'); + claudeToken: 'must-not-combine', imageId })).toThrow('only'); expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'] })).toThrow('OAuth'); + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId })).toThrow('OAuth'); const claudeProfile = createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), - filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], claudeToken: 'serialization-sentinel' }); + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], imageId, + claudeToken: 'serialization-sentinel' }); expect(JSON.stringify(claudeProfile)).not.toContain('serialization-sentinel'); expect(() => createValidatedContainer(claudeProfile)).toThrow('OAuth environment credential'); expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), - filesystems: data.filesystems, inputDirectory: data.input, command: [] })).toThrow('argv'); + filesystems: data.filesystems, inputDirectory: data.input, command: [], imageId })).toThrow('argv'); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + filesystems: data.filesystems, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, + imageId: AGENT_IMAGE })).toThrow('immutable built image ID'); chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => profile(data, 'planning', ['true'])).toThrow('only one bounded'); }); it('rejects unexpected host mounts and unbounded task volumes after Docker resolves them', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); - const imageIndex = valid.args.indexOf(AGENT_IMAGE); + const imageIndex = valid.args.indexOf(imageId); const extraMountArgs = [...valid.args.slice(0, imageIndex), '--mount', 'type=bind,source=/tmp,target=/unexpected,readonly', ...valid.args.slice(imageIndex)]; docker(...extraMountArgs); containers.add(valid.name); @@ -172,26 +175,55 @@ describe('real Docker agent isolation', () => { } finally { spawnSync('docker', ['volume', 'rm', '--force', rogue], { stdio: 'ignore' }); } }, 60_000); + it('rejects a caller-mutated network before the container can start', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const args = valid.args.map(value => value === '--network=none' ? '--network=bridge' : value); + docker(...args); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + const state = JSON.parse(docker('container', 'inspect', valid.name))[0] as { State: { Status: string } }; + expect(state.State.Status).toBe('created'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + }, 60_000); + + it('creates containers from the captured immutable image rather than its mutable tag', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + expect(valid.expectedImage).toBe(imageId); + expect(valid.args).toContain(imageId); + expect(valid.args).not.toContain(AGENT_IMAGE); + expect(() => prepareTaskFilesystems(data.clone.directory, { + workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, + }, AGENT_IMAGE)).toThrow('immutable built image ID'); + }); + if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { it('runs the authenticated Codex startup path with isolated writable state', () => { const data = fixture(), authFile = process.env.CODEBOOST_CODEX_AUTH_FILE; if (!authFile) throw new Error('CODEBOOST_CODEX_AUTH_FILE is required.'); - const output = runContainer(profile(data, 'planning', ['sh', '-c', [ + const authProfile = profile(data, 'planning', ['sh', '-c', [ "codex exec --sandbox read-only --skip-git-repo-check --output-last-message /tmp/codex-output.txt 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.' >/tmp/codex-events.jsonl", 'grep -Fx codeboost-schema-marker /tmp/codex-output.txt', - ].join('; ')], { authProbe: true, codexAuthFile: authFile }), 5 * 60_000); + ].join('; ')], { authProbe: true, codexAuthFile: authFile }); + const args = authProfile.args.map(value => value === '--network=none' ? '--network=bridge' : value); + docker(...args); containers.add(authProfile.name); + const output = docker('start', '--attach', authProfile.name); + docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); expect(output).toBe('codeboost-schema-marker'); }, 6 * 60_000); it('runs the authenticated Claude startup path with only its OAuth token', () => { const data = fixture(), token = process.env.CLAUDE_CODE_OAUTH_TOKEN; if (!token) throw new Error('CLAUDE_CODE_OAUTH_TOKEN is required.'); - const output = runContainer(profile(data, 'planning', ['claude', '-p', + const authProfile = profile(data, 'planning', ['claude', '-p', 'Read /run/codeboost-input/schema.json and reply only with the exact value of its probe field.', '--output-format', 'json', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--allowedTools', 'Read', '--add-dir', '/run/codeboost-input', - '--disallowedTools', 'WebFetch,WebSearch'], { vendor: 'claude', authProbe: true, claudeToken: token }), - 5 * 60_000, { CLAUDE_CODE_OAUTH_TOKEN: token }); + '--disallowedTools', 'WebFetch,WebSearch'], { vendor: 'claude', authProbe: true, claudeToken: token }); + const args = authProfile.args.map(value => value === '--network=none' ? '--network=bridge' : value); + const result = execFileSync('docker', args, { encoding: 'utf8', timeout: 60_000, + env: { PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, CLAUDE_CODE_OAUTH_TOKEN: token } }); + void result; containers.add(authProfile.name); + const output = docker('start', '--attach', authProfile.name); + docker('rm', '--force', authProfile.name); containers.delete(authProfile.name); const envelope = JSON.parse(output) as { result?: string; is_error?: boolean }; expect(envelope.is_error).not.toBe(true); expect(envelope.result?.trim()).toBe('codeboost-schema-marker'); From f86903105bde64611a6f7b74ddbc9b3bfc836798 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:02:28 -0700 Subject: [PATCH 03/17] Close D2 profile and validation trust gaps --- agents/container/image.ts | 1 + agents/container/probe.sh | 2 + agents/container/profile.ts | 80 +++++++++++++++++++++++++++++------- agents/container/run.ts | 28 ++++++++++--- test/agent-container.test.ts | 35 ++++++++++++++-- 5 files changed, 123 insertions(+), 23 deletions(-) diff --git a/agents/container/image.ts b/agents/container/image.ts index 15e8c7f..876a029 100644 --- a/agents/container/image.ts +++ b/agents/container/image.ts @@ -23,6 +23,7 @@ export function buildAgentImage(timeoutMs = 10 * 60_000): string { const inspect = JSON.parse(execFileSync('docker', ['image', 'inspect', AGENT_IMAGE], { encoding: 'utf8', timeout: remaining(), stdio: ['ignore', 'pipe', 'pipe'], }))[0] as { Id?: string; Config?: { User?: string; Labels?: Record } }; + remaining(); const labels = inspect.Config?.Labels ?? {}; if (!inspect.Id?.startsWith('sha256:') || inspect.Config?.User !== '10001:10001' || labels['org.opencontainers.image.base.name'] !== BASE_IMAGE diff --git a/agents/container/probe.sh b/agents/container/probe.sh index 78ec706..df56239 100644 --- a/agents/container/probe.sh +++ b/agents/container/probe.sh @@ -1,5 +1,7 @@ #!/bin/sh set -eu +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +export PATH fail() { printf 'codeboost isolation probe: %s\n' "$1" >&2; exit 78; } mount_options() { findmnt --noheadings --output OPTIONS --target "$1" 2>/dev/null || fail "missing mount: $1"; } diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 28fb510..3df0e75 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { lstatSync, readdirSync, realpathSync } from 'node:fs'; +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; import type { InvocationInput, Phase } from '../contract.ts'; export interface TaskFilesystems { @@ -32,6 +32,65 @@ export interface ProfileOptions { readonly claudeToken?: string; } +interface FileIdentity { + readonly path: string; + readonly dev: number; + readonly ino: number; + readonly mode: number; + readonly nlink: number; + readonly size: number; + readonly mtimeMs: number; + readonly digest: string; +} +interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity } +const identities = new WeakMap(); + +const captureFile = (path: string, kind: string): FileIdentity => { + let fd: number | undefined; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = fstatSync(fd); + if (!before.isFile() || before.nlink !== 1 || before.size > 1024 * 1024) + throw new Error(`${kind} must be a bounded, unlinked regular file.`); + const content = readFileSync(fd); + const after = fstatSync(fd); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) + throw new Error(`${kind} changed while its identity was captured.`); + return Object.freeze({ path, dev: after.dev, ino: after.ino, mode: after.mode, nlink: after.nlink, + size: after.size, mtimeMs: after.mtimeMs, digest: createHash('sha256').update(content).digest('hex') }); + } finally { if (fd !== undefined) closeSync(fd); } +}; +const sameFile = (actual: FileIdentity, expected: FileIdentity) => actual.path === expected.path + && actual.dev === expected.dev && actual.ino === expected.ino && actual.mode === expected.mode + && actual.nlink === expected.nlink && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs + && actual.digest === expected.digest; +const captureInput = (directory: string): ProfileIdentity => { + const stat = lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o005) !== 0o005) + throw new Error('Schema input directory must be a container-readable real directory.'); + const canonical = mountSource(realpathSync(directory), 'Schema input'); + const entries = readdirSync(canonical); + if (entries.length !== 1 || entries[0] !== 'schema.json') + throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.'); + const schema = captureFile(`${canonical}/schema.json`, 'Schema input'); + if ((schema.mode & 0o004) === 0) throw new Error('Schema input must be container-readable.'); + return Object.freeze({ inputDirectory: canonical, schema }); +}; + +/** Internal authenticity and host-file revalidation used at every launch boundary. */ +export function assertContainerProfile(profile: ContainerProfile): void { + const expected = identities.get(profile); + if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); + const actual = captureInput(expected.inputDirectory); + if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) + throw new Error('Schema input changed after the profile was captured.'); + if (expected.auth) { + const auth = captureFile(expected.auth.path, 'Codex auth'); + if (!sameFile(auth, expected.auth)) throw new Error('Codex auth changed after the profile was captured.'); + } +} + const safeName = (value: string) => { const prefix = value.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 24); return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`; @@ -49,14 +108,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container command must be a complete literal argv array.'); if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) throw new Error('Container profile requires the immutable built image ID.'); - const inputStat = options.inputDirectory ? lstatSync(options.inputDirectory) : undefined; - if (!inputStat?.isDirectory() || (inputStat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be container-readable.'); - const inputDirectory = mountSource(realpathSync(options.inputDirectory), 'Schema input'); - const entries = readdirSync(inputDirectory); - const schema = entries.length === 1 && entries[0] === 'schema.json' ? lstatSync(`${inputDirectory}/schema.json`) : undefined; - if (!schema?.isFile() || schema.isSymbolicLink() || schema.nlink !== 1 || schema.size > 1024 * 1024 - || (schema.mode & 0o004) === 0) - throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.'); + const inputIdentity = captureInput(options.inputDirectory); + const inputDirectory = inputIdentity.inputDirectory; if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile)) @@ -68,10 +121,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) throw new Error('Codex auth must be a direct regular file, not a link.'); const codexAuthFile = options.codexAuthFile ? mountSource(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; - if (codexAuthFile) { - const auth = lstatSync(codexAuthFile); - if (!auth.isFile() || auth.isSymbolicLink() || auth.size > 1024 * 1024) throw new Error('Codex auth must be a bounded regular file.'); - } + const authIdentity = codexAuthFile ? captureFile(codexAuthFile, 'Codex auth') : undefined; const name = `codeboost-agent-${safeName(invocation.attemptId)}`; const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', @@ -93,8 +143,10 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); args.push(options.imageId, ...options.command); const capturedFilesystems = Object.freeze({ ...filesystems }); - return Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, + const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory, codexAuthFile, command: Object.freeze([...options.command]) }); + identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity })); + return profile; } diff --git a/agents/container/run.ts b/agents/container/run.ts index 92715bb..709c902 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -1,7 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; -import type { ContainerProfile, TaskFilesystems } from './profile.ts'; +import { assertContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ @@ -30,6 +30,8 @@ const createDeadline = (timeoutMs: number) => { }; }; const resourceName = (kind: string) => `codeboost-${kind}-${randomUUID()}`; +const exactNoNewPrivileges = (options: string[] | null | undefined) => options?.length === 1 + && (options[0] === 'no-new-privileges' || options[0] === 'no-new-privileges:true'); const canonicalDockerBindSource = (source: string) => { const desktopHostPath = source.startsWith('/host_mnt/') ? source.slice('/host_mnt'.length) : source; try { return realpathSync(desktopHostPath); } catch { return source; } @@ -82,6 +84,7 @@ export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskSto '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, '--entrypoint', 'sh', imageId, '-c', seed], { timeoutMs: remaining() }); + remaining(); return Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); } catch (error) { spawnSync('docker', ['rm', '--force', keeper], { env: dockerEnvironment(), stdio: 'ignore' }); @@ -104,10 +107,11 @@ type Inspect = { /** Validate daemon-resolved configuration before starting an agent. */ export function validateContainer(container: string, profile: ContainerProfile, timeoutMs = 30_000): void { const remaining = createDeadline(timeoutMs); + assertContainerProfile(profile); const inspect = JSON.parse(docker(['container', 'inspect', container], { timeoutMs: remaining() }))[0] as Inspect | undefined; if (!inspect) throw new Error('Docker did not return the created container.'); const image = JSON.parse(docker(['image', 'inspect', profile.expectedImage], { timeoutMs: remaining() }))[0] as - { Id?: string; Config?: { User?: string; Entrypoint?: string[]; Labels?: Record } } | undefined; + { Id?: string; Config?: { User?: string; Env?: string[]; Entrypoint?: string[]; Labels?: Record } } | undefined; const imageId = image?.Id, labels = image?.Config?.Labels ?? {}; const host = inspect.HostConfig; if (!imageId || imageId !== profile.expectedImage || inspect.Image !== profile.expectedImage @@ -124,7 +128,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || JSON.stringify(inspect.Config.Cmd) !== JSON.stringify(profile.command) || !host.ReadonlyRootfs || host.Privileged || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') - || !host.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) + || !exactNoNewPrivileges(host.SecurityOpt) || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) @@ -183,7 +187,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') - || !keeper.HostConfig.SecurityOpt?.some(value => value.startsWith('no-new-privileges')) + || !exactNoNewPrivileges(keeper.HostConfig.SecurityOpt) || keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume || keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume) throw new Error('Task filesystems must remain owned by their trusted keeper.'); @@ -197,12 +201,14 @@ export function validateContainer(container: string, profile: ContainerProfile, if (inspect.Config.Env.some(value => value.indexOf('=') < 1)) throw new Error('Container environment is malformed.'); const names = inspect.Config.Env.map(value => value.slice(0, value.indexOf('='))); const environment = new Map(inspect.Config.Env.map(value => [value.slice(0, value.indexOf('=')), value.slice(value.indexOf('=') + 1)])); + const imageEnvironment = new Map((image?.Config?.Env ?? []).map(value => [value.slice(0, value.indexOf('=')), value.slice(value.indexOf('=') + 1)])); const allowedEnvironment = new Set(['PATH', 'NODE_VERSION', 'YARN_VERSION', 'HOME', 'CODEBOOST_PHASE', 'CODEBOOST_VENDOR', 'CODEBOOST_WORK_BYTES', 'CODEBOOST_WORK_INODES', 'CODEBOOST_METADATA_BYTES', 'CODEBOOST_METADATA_INODES', 'npm_config_cache', 'XDG_CACHE_HOME', ...(profile.vendor === 'codex' ? ['CODEX_HOME'] : ['CLAUDE_CODE_OAUTH_TOKEN'])]); if (new Set(names).size !== names.length || names.some(name => !allowedEnvironment.has(name))) throw new Error('Container includes an unexpected environment variable.'); - if (environment.get('HOME') !== '/home/codeboost' || environment.get('CODEBOOST_PHASE') !== profile.phase + if (environment.get('PATH') !== imageEnvironment.get('PATH') + || environment.get('HOME') !== '/home/codeboost' || environment.get('CODEBOOST_PHASE') !== profile.phase || environment.get('CODEBOOST_VENDOR') !== profile.vendor || environment.get('CODEBOOST_WORK_BYTES') !== String(profile.filesystems.workBytes) || environment.get('CODEBOOST_WORK_INODES') !== String(profile.filesystems.workInodes) @@ -212,15 +218,20 @@ export function validateContainer(container: string, profile: ContainerProfile, if (profile.vendor === 'codex' && names.includes('CLAUDE_CODE_OAUTH_TOKEN')) throw new Error('Credential profiles must not be combined.'); if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) throw new Error('Credential profiles must not be combined.'); + assertContainerProfile(profile); + remaining(); } export function createValidatedContainer(profile: ContainerProfile, timeoutMs = 30_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); validateSecrets(profile, secrets); + assertContainerProfile(profile); try { docker(profile.args, { timeoutMs: remaining(), secrets }); validateContainer(profile.name, profile, remaining()); + assertContainerProfile(profile); + remaining(); return profile.name; } catch (error) { spawnSync('docker', ['rm', '--force', profile.name], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); @@ -232,7 +243,12 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); const container = createValidatedContainer(profile, remaining(), secrets); - try { return docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); } + try { + assertContainerProfile(profile); + const output = docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); + remaining(); + return output; + } finally { spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); } } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 08e3926..ef17210 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -167,14 +167,43 @@ describe('real Docker agent isolation', () => { const rogue = `codeboost-work-${randomUUID()}`; docker('volume', 'create', rogue); try { const rogueArgs = valid.args.map(value => value.replace(data.filesystems.workVolume, rogue)); - const rogueProfile = Object.freeze({ ...valid, args: Object.freeze(rogueArgs), - filesystems: Object.freeze({ ...valid.filesystems, workVolume: rogue }) }); docker(...rogueArgs); containers.add(valid.name); - expect(() => validateContainer(valid.name, rogueProfile)).toThrow('bounded tmpfs allocation'); + expect(() => validateContainer(valid.name, valid)).toThrow('captured identity'); docker('rm', '--force', valid.name); containers.delete(valid.name); } finally { spawnSync('docker', ['volume', 'rm', '--force', rogue], { stdio: 'ignore' }); } }, 60_000); + it('rejects cloned profiles and host inputs changed after capture', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const forged = Object.freeze({ ...valid, inputDirectory: '/', + args: Object.freeze(valid.args.map(value => value.includes(`source=${data.input},`) + ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); + expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); + + chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); + expect(() => createValidatedContainer(valid)).toThrow('only one bounded'); + chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); + + writeFileSync(data.fakeAuth, '{"changed":true}'); + expect(() => createValidatedContainer(valid)).toThrow('Codex auth changed'); + writeFileSync(data.fakeAuth, '{}'); + }); + + it('rejects extra security policies and a PATH that can shadow the startup probe', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const imageIndex = valid.args.indexOf(imageId); + const securityArgs = [...valid.args.slice(0, imageIndex), '--security-opt', 'seccomp=unconfined', + ...valid.args.slice(imageIndex)]; + docker(...securityArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + + const pathArgs = [...valid.args.slice(0, imageIndex), '--env', 'PATH=/work', ...valid.args.slice(imageIndex)]; + docker(...pathArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow(/environment|PATH/); + docker('rm', '--force', valid.name); containers.delete(valid.name); + }, 60_000); + it('rejects a caller-mutated network before the container can start', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const args = valid.args.map(value => value === '--network=none' ? '--network=bridge' : value); From e854ec661816a04f941ab56303cb270279344c51 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:13:53 -0700 Subject: [PATCH 04/17] Require exact D2 capability and mount profiles --- agents/container/profile.ts | 41 ++++++++++++++++++++++++++++++------ agents/container/run.ts | 34 ++++++++++++++++++------------ test/agent-container.test.ts | 36 +++++++++++++++++++++++++------ 3 files changed, 84 insertions(+), 27 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 3df0e75..fbca90e 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,5 +1,8 @@ import { createHash } from 'node:crypto'; -import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, + readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { InvocationInput, Phase } from '../contract.ts'; export interface TaskFilesystems { @@ -42,10 +45,11 @@ interface FileIdentity { readonly mtimeMs: number; readonly digest: string; } -interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity } +interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity; + readonly cleanupDirectory?: string } const identities = new WeakMap(); -const captureFile = (path: string, kind: string): FileIdentity => { +const readCapturedFile = (path: string, kind: string): { identity: FileIdentity; content: Buffer } => { let fd: number | undefined; try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); @@ -57,10 +61,12 @@ const captureFile = (path: string, kind: string): FileIdentity => { if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error(`${kind} changed while its identity was captured.`); - return Object.freeze({ path, dev: after.dev, ino: after.ino, mode: after.mode, nlink: after.nlink, + const identity = Object.freeze({ path, dev: after.dev, ino: after.ino, mode: after.mode, nlink: after.nlink, size: after.size, mtimeMs: after.mtimeMs, digest: createHash('sha256').update(content).digest('hex') }); + return { identity, content }; } finally { if (fd !== undefined) closeSync(fd); } }; +const captureFile = (path: string, kind: string) => readCapturedFile(path, kind).identity; const sameFile = (actual: FileIdentity, expected: FileIdentity) => actual.path === expected.path && actual.dev === expected.dev && actual.ino === expected.ino && actual.mode === expected.mode && actual.nlink === expected.nlink && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs @@ -91,6 +97,14 @@ export function assertContainerProfile(profile: ContainerProfile): void { } } +/** Remove runner-owned credential staging after this one-shot profile settles. */ +export function disposeContainerProfile(profile: ContainerProfile): void { + const identity = identities.get(profile); + if (!identity) return; + identities.delete(profile); + if (identity.cleanupDirectory) rmSync(identity.cleanupDirectory, { recursive: true, force: true }); +} + const safeName = (value: string) => { const prefix = value.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 24); return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`; @@ -120,8 +134,21 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil || !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.'); if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) throw new Error('Codex auth must be a direct regular file, not a link.'); - const codexAuthFile = options.codexAuthFile ? mountSource(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; - const authIdentity = codexAuthFile ? captureFile(codexAuthFile, 'Codex auth') : undefined; + const sourceAuth = options.codexAuthFile ? readCapturedFile(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; + let cleanupDirectory: string | undefined, codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined; + if (sourceAuth) { + cleanupDirectory = mkdtempSync(join(tmpdir(), 'codeboost-auth-')); + try { + const stagedAuth = join(cleanupDirectory, 'auth.json'); + writeFileSync(stagedAuth, sourceAuth.content, { mode: 0o400, flag: 'wx' }); + chmodSync(stagedAuth, 0o444); + codexAuthFile = mountSource(realpathSync(stagedAuth), 'Codex auth'); + authIdentity = captureFile(codexAuthFile, 'Staged Codex auth'); + } catch (error) { + rmSync(cleanupDirectory, { recursive: true, force: true }); + throw error; + } + } const name = `codeboost-agent-${safeName(invocation.attemptId)}`; const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', @@ -147,6 +174,6 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory, codexAuthFile, command: Object.freeze([...options.command]) }); - identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity })); + identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity, cleanupDirectory })); return profile; } diff --git a/agents/container/run.ts b/agents/container/run.ts index 709c902..42b600a 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -1,7 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; -import { assertContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; +import { assertContainerProfile, disposeContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ @@ -32,6 +32,11 @@ const createDeadline = (timeoutMs: number) => { const resourceName = (kind: string) => `codeboost-${kind}-${randomUUID()}`; const exactNoNewPrivileges = (options: string[] | null | undefined) => options?.length === 1 && (options[0] === 'no-new-privileges' || options[0] === 'no-new-privileges:true'); +export const hasExactOptions = (value: string | undefined, expected: readonly string[]) => { + const parts = value?.split(',') ?? []; + return parts.length === expected.length && new Set(parts).size === parts.length + && expected.every(option => parts.includes(option)); +}; const canonicalDockerBindSource = (source: string) => { const desktopHostPath = source.startsWith('/host_mnt/') ? source.slice('/host_mnt'.length) : source; try { return realpathSync(desktopHostPath); } catch { return source; } @@ -98,6 +103,7 @@ type Inspect = { Image: string; Config: { Image: string; User: string; Env: string[]; Entrypoint: string[] | null; Cmd: string[] | null; WorkingDir: string }; HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; + CapAdd: string[] | null; NetworkMode: string; PidMode: string; IpcMode: string; PidsLimit: number; Memory: number; NanoCpus: number; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; @@ -127,7 +133,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || JSON.stringify(inspect.Config.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) || JSON.stringify(inspect.Config.Cmd) !== JSON.stringify(profile.command) || !host.ReadonlyRootfs || host.Privileged - || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0 || !exactNoNewPrivileges(host.SecurityOpt) || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 @@ -142,8 +148,7 @@ export function validateContainer(container: string, profile: ContainerProfile, ]); if (Object.keys(tmpfs).length !== expectedTmpfs.size) throw new Error('Container tmpfs mount set changed.'); for (const [path, expected] of expectedTmpfs) { - const actual = new Set((tmpfs[path] ?? '').split(',')); - if (expected.some(option => !actual.has(option))) throw new Error(`Container tmpfs ${path} is missing required options.`); + if (!hasExactOptions(tmpfs[path], expected)) throw new Error(`Container tmpfs ${path} options changed.`); } const mounts = new Map(inspect.Mounts.map(item => [item.Destination, item])); const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', @@ -171,22 +176,21 @@ export function validateContainer(container: string, profile: ContainerProfile, const expected = expectedVolumes.get(volume.Name), options = volume.Options ?? {}, optionString = options.o ?? ''; if (!expected || volume.Driver !== 'local' || options.type !== 'tmpfs' || options.device !== 'tmpfs' || volume.Labels?.['io.codeboost.task-storage'] !== expected[0] - || !optionString.split(',').includes(`size=${expected[1]}`) - || !optionString.split(',').includes(`nr_inodes=${expected[2]}`) - || !optionString.split(',').includes('uid=10001') || !optionString.split(',').includes('gid=10001') - || !optionString.split(',').includes('mode=0755') || !optionString.split(',').includes('nosuid') - || !optionString.split(',').includes('nodev')) + || !hasExactOptions(optionString, [`size=${expected[1]}`, `nr_inodes=${expected[2]}`, + 'uid=10001', 'gid=10001', 'mode=0755', 'nosuid', 'nodev'])) throw new Error('Task volume does not match its bounded tmpfs allocation.'); } const keeper = JSON.parse(docker(['container', 'inspect', profile.filesystems.keeper], { timeoutMs: remaining() }))[0] as { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record }; HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; NetworkMode?: string; CapDrop?: string[] | null; - SecurityOpt?: string[] | null }; Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; + CapAdd?: string[] | null; SecurityOpt?: string[] | null }; + Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); if (!keeper?.State?.Running || keeper.Config?.Image !== profile.expectedImage || keeper.Config?.User !== '10001:10001' || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') + || (keeper.HostConfig.CapAdd?.length ?? 0) !== 0 || !exactNoNewPrivileges(keeper.HostConfig.SecurityOpt) || keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume || keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume) @@ -225,9 +229,9 @@ export function validateContainer(container: string, profile: ContainerProfile, export function createValidatedContainer(profile: ContainerProfile, timeoutMs = 30_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); - validateSecrets(profile, secrets); - assertContainerProfile(profile); try { + validateSecrets(profile, secrets); + assertContainerProfile(profile); docker(profile.args, { timeoutMs: remaining(), secrets }); validateContainer(profile.name, profile, remaining()); assertContainerProfile(profile); @@ -235,6 +239,7 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs = return profile.name; } catch (error) { spawnSync('docker', ['rm', '--force', profile.name], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); + disposeContainerProfile(profile); throw error; } } @@ -249,7 +254,10 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, remaining(); return output; } - finally { spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); } + finally { + spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); + disposeContainerProfile(profile); + } } export function removeTaskFilesystems(filesystems: TaskFilesystems): void { diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index ef17210..df12caa 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -1,19 +1,20 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; import { AGENT_IMAGE, buildAgentImage } from '../agents/container/image.ts'; -import { createContainerProfile } from '../agents/container/profile.ts'; +import { createContainerProfile, disposeContainerProfile } from '../agents/container/profile.ts'; import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, - validateContainer } from '../agents/container/run.ts'; + hasExactOptions, validateContainer } from '../agents/container/run.ts'; import { createTaskClone } from '../git/clone.ts'; const roots: string[] = []; const taskFilesystems: ReturnType[] = []; const containers = new Set(); +const profiles: ReturnType[] = []; let imageId = ''; const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); @@ -54,6 +55,7 @@ function profile(data: ReturnType, phase: Phase, command: string imageId, codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); + profiles.push(base); return base; } @@ -61,6 +63,7 @@ beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000); afterAll(() => { for (const container of containers) spawnSync('docker', ['rm', '--force', container], { stdio: 'ignore' }); for (const filesystems of taskFilesystems.reverse()) removeTaskFilesystems(filesystems); + for (const profile of profiles) disposeContainerProfile(profile); for (const root of roots.reverse()) { chmodSync(join(root, 'input'), 0o700); rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); @@ -180,13 +183,15 @@ describe('real Docker agent isolation', () => { ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); + writeFileSync(data.fakeAuth, '{"changed":true}'); + expect(valid.codexAuthFile).not.toBe(data.fakeAuth); + expect(readFileSync(valid.codexAuthFile!, 'utf8')).toBe('{}'); + expect(statSync(valid.codexAuthFile!).mode & 0o777).toBe(0o444); + writeFileSync(data.fakeAuth, '{}'); + chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => createValidatedContainer(valid)).toThrow('only one bounded'); chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); - - writeFileSync(data.fakeAuth, '{"changed":true}'); - expect(() => createValidatedContainer(valid)).toThrow('Codex auth changed'); - writeFileSync(data.fakeAuth, '{}'); }); it('rejects extra security policies and a PATH that can shadow the startup probe', () => { @@ -204,6 +209,23 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); + it('rejects added capabilities and conflicting or duplicate filesystem options', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const imageIndex = valid.args.indexOf(imageId); + const args = [...valid.args.slice(0, imageIndex), '--cap-add=SYS_ADMIN', ...valid.args.slice(imageIndex)]; + docker(...args); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + const state = JSON.parse(docker('container', 'inspect', valid.name))[0] as { State: { Status: string } }; + expect(state.State.Status).toBe('created'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + + const expected = ['size=1024', 'nr_inodes=16', 'uid=10001', 'gid=10001', 'mode=0755', 'nosuid', 'nodev']; + expect(hasExactOptions(expected.join(','), expected)).toBe(true); + expect(hasExactOptions([...expected, 'size=2048'].join(','), expected)).toBe(false); + expect(hasExactOptions([...expected, 'dev'].join(','), expected)).toBe(false); + expect(hasExactOptions([...expected, 'nosuid'].join(','), expected)).toBe(false); + }, 60_000); + it('rejects a caller-mutated network before the container can start', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const args = valid.args.map(value => value === '--network=none' ? '--network=bridge' : value); From 25ffe5472a5d3f4379d490e99263d86e1736d387 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:24:03 -0700 Subject: [PATCH 05/17] Trust D2 helper images and settle cleanup --- agents/container/image.ts | 6 ++++++ agents/container/profile.ts | 2 ++ agents/container/run.ts | 33 +++++++++++++++++++++++++++------ test/agent-container.test.ts | 16 ++++++++++++++-- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/agents/container/image.ts b/agents/container/image.ts index 876a029..cdfc655 100644 --- a/agents/container/image.ts +++ b/agents/container/image.ts @@ -8,6 +8,11 @@ export const CODEX_VERSION = '0.153.4'; export const CLAUDE_VERSION = '2.1.281'; const context = dirname(fileURLToPath(import.meta.url)); +const trustedImages = new Set(); + +export function assertBuiltAgentImage(imageId: string): void { + if (!trustedImages.has(imageId)) throw new Error('Agent image was not produced by the trusted validated builder.'); +} export function buildAgentImage(timeoutMs = 10 * 60_000): string { if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Image build requires a finite positive deadline.'); @@ -30,5 +35,6 @@ export function buildAgentImage(timeoutMs = 10 * 60_000): string { || labels['io.codeboost.codex.version'] !== CODEX_VERSION || labels['io.codeboost.claude.version'] !== CLAUDE_VERSION || labels['io.codeboost.profile.version'] !== '1') throw new Error('Built agent image does not match the pinned profile.'); + trustedImages.add(inspect.Id); return inspect.Id; } diff --git a/agents/container/profile.ts b/agents/container/profile.ts index fbca90e..706cfe2 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -4,6 +4,7 @@ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, ope import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { InvocationInput, Phase } from '../contract.ts'; +import { assertBuiltAgentImage } from './image.ts'; export interface TaskFilesystems { readonly keeper: string; @@ -122,6 +123,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container command must be a complete literal argv array.'); if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) throw new Error('Container profile requires the immutable built image ID.'); + assertBuiltAgentImage(options.imageId); const inputIdentity = captureInput(options.inputDirectory); const inputDirectory = inputIdentity.inputDirectory; if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) diff --git a/agents/container/run.ts b/agents/container/run.ts index 42b600a..5c96dda 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; import { assertContainerProfile, disposeContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; -import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; +import { assertBuiltAgentImage, BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, ...secrets, @@ -41,6 +41,19 @@ const canonicalDockerBindSource = (source: string) => { const desktopHostPath = source.startsWith('/host_mnt/') ? source.slice('/host_mnt'.length) : source; try { return realpathSync(desktopHostPath); } catch { return source; } }; +const removeContainerOrThrow = (profile: ContainerProfile) => { + const result = spawnSync('docker', ['rm', '--force', profile.name], { + encoding: 'utf8', timeout: 30_000, env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + const inspect = spawnSync('docker', ['container', 'inspect', profile.name], { + encoding: 'utf8', timeout: 30_000, env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + }); + const absent = inspect.status !== 0 && !inspect.error && /No such (?:object|container)/i.test(inspect.stderr ?? ''); + if (!absent) throw new Error('Failed to confirm removal of the agent container; staged credentials were retained.'); + } + disposeContainerProfile(profile); +}; export interface TaskStorageLimits { readonly workBytes: number; @@ -54,6 +67,7 @@ export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskSto imageId: string, timeoutMs = 60_000): TaskFilesystems { for (const [name, value] of Object.entries(limits)) validLimit(value, name); if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); + assertBuiltAgentImage(imageId); const remaining = createDeadline(timeoutMs); const staging = realpathSync(stagingDirectory); if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); @@ -217,7 +231,9 @@ export function validateContainer(container: string, profile: ContainerProfile, || environment.get('CODEBOOST_WORK_BYTES') !== String(profile.filesystems.workBytes) || environment.get('CODEBOOST_WORK_INODES') !== String(profile.filesystems.workInodes) || environment.get('CODEBOOST_METADATA_BYTES') !== String(profile.filesystems.metadataBytes) - || environment.get('CODEBOOST_METADATA_INODES') !== String(profile.filesystems.metadataInodes)) + || environment.get('CODEBOOST_METADATA_INODES') !== String(profile.filesystems.metadataInodes) + || environment.get('npm_config_cache') !== '/tmp/npm-cache' + || environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache') throw new Error('Container isolation environment changed.'); if (profile.vendor === 'codex' && names.includes('CLAUDE_CODE_OAUTH_TOKEN')) throw new Error('Credential profiles must not be combined.'); if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) @@ -238,8 +254,8 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs = remaining(); return profile.name; } catch (error) { - spawnSync('docker', ['rm', '--force', profile.name], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); - disposeContainerProfile(profile); + try { removeContainerOrThrow(profile); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Container creation failed and cleanup did not settle.'); } throw error; } } @@ -248,15 +264,20 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); const container = createValidatedContainer(profile, remaining(), secrets); + let failure: unknown; try { assertContainerProfile(profile); const output = docker(['start', '--attach', container], { timeoutMs: remaining(), secrets }); remaining(); return output; } + catch (error) { failure = error; throw error; } finally { - spawnSync('docker', ['rm', '--force', container], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); - disposeContainerProfile(profile); + try { removeContainerOrThrow(profile); } + catch (cleanupError) { + if (failure) throw new AggregateError([failure, cleanupError], 'Agent invocation failed and cleanup did not settle.'); + throw cleanupError; + } } } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index df12caa..e5bd020 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.ts'; -import { AGENT_IMAGE, buildAgentImage } from '../agents/container/image.ts'; +import { AGENT_IMAGE, assertBuiltAgentImage, buildAgentImage } from '../agents/container/image.ts'; import { createContainerProfile, disposeContainerProfile } from '../agents/container/profile.ts'; import { createValidatedContainer, prepareTaskFilesystems, removeTaskFilesystems, runContainer, hasExactOptions, validateContainer } from '../agents/container/run.ts'; @@ -194,7 +194,7 @@ describe('real Docker agent isolation', () => { chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); }); - it('rejects extra security policies and a PATH that can shadow the startup probe', () => { + it('rejects extra security policies and environment paths that can escape bounded storage', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const imageIndex = valid.args.indexOf(imageId); const securityArgs = [...valid.args.slice(0, imageIndex), '--security-opt', 'seccomp=unconfined', @@ -207,6 +207,13 @@ describe('real Docker agent isolation', () => { docker(...pathArgs); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow(/environment|PATH/); docker('rm', '--force', valid.name); containers.delete(valid.name); + + for (const changedCache of ['npm_config_cache=/work/npm-cache', 'XDG_CACHE_HOME=/work/xdg-cache']) { + const cacheArgs = [...valid.args.slice(0, imageIndex), '--env', changedCache, ...valid.args.slice(imageIndex)]; + docker(...cacheArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('isolation environment'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + } }, 60_000); it('rejects added capabilities and conflicting or duplicate filesystem options', () => { @@ -241,6 +248,11 @@ describe('real Docker agent isolation', () => { expect(valid.expectedImage).toBe(imageId); expect(valid.args).toContain(imageId); expect(valid.args).not.toContain(AGENT_IMAGE); + const untrustedDigest = `sha256:${'0'.repeat(64)}`; + expect(() => assertBuiltAgentImage(untrustedDigest)).toThrow('trusted validated builder'); + expect(() => prepareTaskFilesystems(data.clone.directory, { + workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, + }, untrustedDigest)).toThrow('trusted validated builder'); expect(() => prepareTaskFilesystems(data.clone.directory, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, AGENT_IMAGE)).toThrow('immutable built image ID'); From a229e8e04dc2d23b811acdaff10e2a256168967b Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:37:31 -0700 Subject: [PATCH 06/17] Bind D2 resources to their owners --- agents/container/profile.ts | 31 ++++---- agents/container/run.ts | 106 ++++++++----------------- agents/container/storage.ts | 145 +++++++++++++++++++++++++++++++++++ test/agent-container.test.ts | 31 +++++++- 4 files changed, 218 insertions(+), 95 deletions(-) create mode 100644 agents/container/storage.ts diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 706cfe2..d6a5d5f 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,20 +1,11 @@ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { InvocationInput, Phase } from '../contract.ts'; import { assertBuiltAgentImage } from './image.ts'; - -export interface TaskFilesystems { - readonly keeper: string; - readonly workVolume: string; - readonly metadataVolume: string; - readonly workBytes: number; - readonly workInodes: number; - readonly metadataBytes: number; - readonly metadataInodes: number; -} +import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts'; export interface ContainerProfile { readonly name: string; readonly args: readonly string[]; @@ -25,6 +16,7 @@ export interface ContainerProfile { readonly inputDirectory: string; readonly codexAuthFile?: string; readonly command: readonly string[]; + readonly ownershipId: string; } export interface ProfileOptions { readonly invocation: InvocationInput; @@ -47,7 +39,8 @@ interface FileIdentity { readonly digest: string; } interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity; - readonly cleanupDirectory?: string } + readonly cleanupDirectory?: string; readonly filesystems: TaskFilesystems; readonly clone: InvocationInput['clone'] } +type InputIdentity = Pick; const identities = new WeakMap(); const readCapturedFile = (path: string, kind: string): { identity: FileIdentity; content: Buffer } => { @@ -72,7 +65,7 @@ const sameFile = (actual: FileIdentity, expected: FileIdentity) => actual.path = && actual.dev === expected.dev && actual.ino === expected.ino && actual.mode === expected.mode && actual.nlink === expected.nlink && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs && actual.digest === expected.digest; -const captureInput = (directory: string): ProfileIdentity => { +const captureInput = (directory: string): InputIdentity => { const stat = lstatSync(directory); if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be a container-readable real directory.'); @@ -89,6 +82,7 @@ const captureInput = (directory: string): ProfileIdentity => { export function assertContainerProfile(profile: ContainerProfile): void { const expected = identities.get(profile); if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); + assertTaskFilesystems(expected.filesystems, expected.clone); const actual = captureInput(expected.inputDirectory); if (actual.inputDirectory !== expected.inputDirectory || !sameFile(actual.schema, expected.schema)) throw new Error('Schema input changed after the profile was captured.'); @@ -124,6 +118,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) throw new Error('Container profile requires the immutable built image ID.'); assertBuiltAgentImage(options.imageId); + assertTaskFilesystems(filesystems, invocation.clone); const inputIdentity = captureInput(options.inputDirectory); const inputDirectory = inputIdentity.inputDirectory; if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) @@ -151,11 +146,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw error; } } - const name = `codeboost-agent-${safeName(invocation.attemptId)}`; + const name = `codeboost-agent-${safeName(invocation.attemptId)}`, ownershipId = randomUUID(); const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1', '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + '--label', `io.codeboost.invocation=${ownershipId}`, '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, @@ -171,11 +167,12 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); args.push(options.imageId, ...options.command); - const capturedFilesystems = Object.freeze({ ...filesystems }); + const capturedFilesystems = filesystems; const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, phase: invocation.phase, vendor: invocation.vendor, filesystems: capturedFilesystems, inputDirectory, codexAuthFile, - command: Object.freeze([...options.command]) }); - identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity, cleanupDirectory })); + command: Object.freeze([...options.command]), ownershipId }); + identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity, cleanupDirectory, + filesystems, clone: invocation.clone })); return profile; } diff --git a/agents/container/run.ts b/agents/container/run.ts index 5c96dda..7ae6fb8 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -1,8 +1,10 @@ import { execFileSync, spawnSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { lstatSync, realpathSync } from 'node:fs'; -import { assertContainerProfile, disposeContainerProfile, type ContainerProfile, type TaskFilesystems } from './profile.ts'; -import { assertBuiltAgentImage, BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; +import { realpathSync } from 'node:fs'; +import { assertContainerProfile, disposeContainerProfile, type ContainerProfile } from './profile.ts'; +import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; +import { taskFilesystemAllocationId } from './storage.ts'; +export { prepareTaskFilesystems, removeTaskFilesystems } from './storage.ts'; +export type { TaskFilesystems, TaskStorageLimits } from './storage.ts'; const dockerEnvironment = (secrets: Readonly> = {}) => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST, ...secrets, @@ -29,7 +31,6 @@ const createDeadline = (timeoutMs: number) => { return value; }; }; -const resourceName = (kind: string) => `codeboost-${kind}-${randomUUID()}`; const exactNoNewPrivileges = (options: string[] | null | undefined) => options?.length === 1 && (options[0] === 'no-new-privileges' || options[0] === 'no-new-privileges:true'); export const hasExactOptions = (value: string | undefined, expected: readonly string[]) => { @@ -42,80 +43,39 @@ const canonicalDockerBindSource = (source: string) => { try { return realpathSync(desktopHostPath); } catch { return source; } }; const removeContainerOrThrow = (profile: ContainerProfile) => { + const remaining = createDeadline(30_000); + const before = spawnSync('docker', ['container', 'inspect', profile.name], { + encoding: 'utf8', timeout: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + }); + if (before.status !== 0) { + const missing = !before.error && /No such (?:object|container)/i.test(`${before.stdout ?? ''}\n${before.stderr ?? ''}`); + if (!missing) throw new Error('Failed to establish ownership of the agent container; staged credentials were retained.'); + disposeContainerProfile(profile); + return; + } + const inspected = JSON.parse(before.stdout || '[]')[0] as { Config?: { Labels?: Record } } | undefined; + if (inspected?.Config?.Labels?.['io.codeboost.invocation'] !== profile.ownershipId) { + disposeContainerProfile(profile); + return; + } const result = spawnSync('docker', ['rm', '--force', profile.name], { - encoding: 'utf8', timeout: 30_000, env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', timeout: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], }); if (result.status !== 0) { const inspect = spawnSync('docker', ['container', 'inspect', profile.name], { - encoding: 'utf8', timeout: 30_000, env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', timeout: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], }); - const absent = inspect.status !== 0 && !inspect.error && /No such (?:object|container)/i.test(inspect.stderr ?? ''); + const absent = inspect.status !== 0 && !inspect.error + && /No such (?:object|container)/i.test(`${inspect.stdout ?? ''}\n${inspect.stderr ?? ''}`); if (!absent) throw new Error('Failed to confirm removal of the agent container; staged credentials were retained.'); } disposeContainerProfile(profile); }; -export interface TaskStorageLimits { - readonly workBytes: number; - readonly workInodes: number; - readonly metadataBytes: number; - readonly metadataInodes: number; -} - -/** Allocate bounded, engine-owned task filesystems and keep them mounted. */ -export function prepareTaskFilesystems(stagingDirectory: string, limits: TaskStorageLimits, - imageId: string, timeoutMs = 60_000): TaskFilesystems { - for (const [name, value] of Object.entries(limits)) validLimit(value, name); - if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); - assertBuiltAgentImage(imageId); - const remaining = createDeadline(timeoutMs); - const staging = realpathSync(stagingDirectory); - if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); - if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); - const workVolume = resourceName('work'), metadataVolume = resourceName('metadata'), keeper = resourceName('keeper'); - const createdVolumes: string[] = []; - try { - for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes], - ['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) { - docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', - '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, - '--label', `io.codeboost.task-storage=${kind}`, name], { timeoutMs: remaining() }); - createdVolumes.push(name); - } - const seed = [ - 'set -eu', - 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/', - 'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/', - 'rm -rf /work/.git', - 'mkdir /work/.git', - 'chown -R 10001:10001 /work /metadata', - ].join('; '); - docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', - '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', - '--mount', `type=volume,source=${workVolume},target=/work`, - '--mount', `type=volume,source=${metadataVolume},target=/metadata`, - '--label', 'io.codeboost.task-storage=keeper', '--entrypoint', 'sleep', imageId, 'infinity'], - { timeoutMs: remaining() }); - docker(['run', '--rm', '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', - '--cap-add=CHOWN', '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32', - '--memory=128m', '--cpus=.25', - '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, - '--mount', `type=volume,source=${workVolume},target=/work`, - '--mount', `type=volume,source=${metadataVolume},target=/metadata`, - '--entrypoint', 'sh', imageId, '-c', seed], { timeoutMs: remaining() }); - remaining(); - return Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); - } catch (error) { - spawnSync('docker', ['rm', '--force', keeper], { env: dockerEnvironment(), stdio: 'ignore' }); - for (const volume of createdVolumes.reverse()) - spawnSync('docker', ['volume', 'rm', '--force', volume], { env: dockerEnvironment(), stdio: 'ignore' }); - throw error; - } -} - type Inspect = { Image: string; - Config: { Image: string; User: string; Env: string[]; Entrypoint: string[] | null; Cmd: string[] | null; WorkingDir: string }; + Config: { Image: string; User: string; Env: string[]; Entrypoint: string[] | null; Cmd: string[] | null; + WorkingDir: string; Labels: Record | null }; HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; CapAdd: string[] | null; NetworkMode: string; PidMode: string; IpcMode: string; PidsLimit: number; Memory: number; NanoCpus: number; @@ -146,6 +106,7 @@ export function validateContainer(container: string, profile: ContainerProfile, if (inspect.Config.User !== '10001:10001' || inspect.Config.WorkingDir !== '/work' || JSON.stringify(inspect.Config.Entrypoint) !== JSON.stringify(['/usr/local/bin/codeboost-container-probe']) || JSON.stringify(inspect.Config.Cmd) !== JSON.stringify(profile.command) + || inspect.Config.Labels?.['io.codeboost.invocation'] !== profile.ownershipId || !host.ReadonlyRootfs || host.Privileged || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0 || !exactNoNewPrivileges(host.SecurityOpt) @@ -182,6 +143,7 @@ export function validateContainer(container: string, profile: ContainerProfile, if (work.Source === metadata.Source) throw new Error('Worktree and Git metadata must use separate filesystems.'); const volumes = JSON.parse(docker(['volume', 'inspect', work.Name!, metadata.Name!], { timeoutMs: remaining() })) as Array<{ Name: string; Driver: string; Labels: Record | null; Options: Record | null }>; + const allocationId = taskFilesystemAllocationId(profile.filesystems); const expectedVolumes = new Map([ [work.Name!, ['work', String(profile.filesystems.workBytes), String(profile.filesystems.workInodes)]], [metadata.Name!, ['metadata', String(profile.filesystems.metadataBytes), String(profile.filesystems.metadataInodes)]], @@ -190,6 +152,7 @@ export function validateContainer(container: string, profile: ContainerProfile, const expected = expectedVolumes.get(volume.Name), options = volume.Options ?? {}, optionString = options.o ?? ''; if (!expected || volume.Driver !== 'local' || options.type !== 'tmpfs' || options.device !== 'tmpfs' || volume.Labels?.['io.codeboost.task-storage'] !== expected[0] + || volume.Labels?.['io.codeboost.allocation'] !== allocationId || !hasExactOptions(optionString, [`size=${expected[1]}`, `nr_inodes=${expected[2]}`, 'uid=10001', 'gid=10001', 'mode=0755', 'nosuid', 'nodev'])) throw new Error('Task volume does not match its bounded tmpfs allocation.'); @@ -201,7 +164,8 @@ export function validateContainer(container: string, profile: ContainerProfile, Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); if (!keeper?.State?.Running || keeper.Config?.Image !== profile.expectedImage || keeper.Config?.User !== '10001:10001' - || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' || !keeper.HostConfig?.ReadonlyRootfs + || keeper.Config?.Labels?.['io.codeboost.task-storage'] !== 'keeper' + || keeper.Config?.Labels?.['io.codeboost.allocation'] !== allocationId || !keeper.HostConfig?.ReadonlyRootfs || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (keeper.HostConfig.CapAdd?.length ?? 0) !== 0 @@ -280,9 +244,3 @@ export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, } } } - -export function removeTaskFilesystems(filesystems: TaskFilesystems): void { - spawnSync('docker', ['rm', '--force', filesystems.keeper], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); - for (const volume of [filesystems.metadataVolume, filesystems.workVolume]) - spawnSync('docker', ['volume', 'rm', '--force', volume], { timeout: 30_000, env: dockerEnvironment(), stdio: 'ignore' }); -} diff --git a/agents/container/storage.ts b/agents/container/storage.ts new file mode 100644 index 0000000..7e5e8fe --- /dev/null +++ b/agents/container/storage.ts @@ -0,0 +1,145 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstatSync, realpathSync } from 'node:fs'; +import type { TaskClone } from '../contract.ts'; +import { assertBuiltAgentImage } from './image.ts'; + +export interface TaskFilesystems { + readonly keeper: string; + readonly workVolume: string; + readonly metadataVolume: string; + readonly workBytes: number; + readonly workInodes: number; + readonly metadataBytes: number; + readonly metadataInodes: number; +} +export interface TaskStorageLimits { + readonly workBytes: number; + readonly workInodes: number; + readonly metadataBytes: number; + readonly metadataInodes: number; +} + +interface AllocationIdentity { + readonly allocationId: string; + readonly clone: Readonly; + readonly limits: Readonly; +} +const allocations = new WeakMap(); +const dockerEnvironment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); +const validLimit = (value: number, name: string) => { + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer.`); +}; +const createDeadline = (timeoutMs: number) => { + validLimit(timeoutMs, 'timeoutMs'); + const deadline = performance.now() + timeoutMs; + return () => { + const value = Math.ceil(deadline - performance.now()); + if (value <= 0) throw new Error('Docker operation exceeded its overall deadline.'); + return value; + }; +}; +const docker = (args: readonly string[], timeoutMs: number) => execFileSync('docker', [...args], { + encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', env: dockerEnvironment(), + stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); +const absent = (result: ReturnType) => result.status !== 0 && !result.error + && /No such (?:object|container|volume)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); +const remove = (args: readonly string[], inspectArgs: readonly string[], remaining: () => number, kind: string, + allocationId: string) => { + const before = spawnSync('docker', [...inspectArgs], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (before.status !== 0) { + if (absent(before)) return; + throw new Error(`Failed to establish ownership of ${kind}.`); + } + const inspected = JSON.parse(before.stdout || '[]')[0] as + { Labels?: Record; Config?: { Labels?: Record } } | undefined; + const labels = inspected?.Labels ?? inspected?.Config?.Labels; + if (labels?.['io.codeboost.allocation'] !== allocationId) throw new Error(`Refused to remove unowned ${kind}.`); + const result = spawnSync('docker', [...args], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.status === 0) return; + const inspect = spawnSync('docker', [...inspectArgs], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (!absent(inspect)) throw new Error(`Failed to confirm removal of ${kind}.`); +}; +const cleanup = (keeper: string, volumes: readonly string[], allocationId: string, timeoutMs = 30_000) => { + const remaining = createDeadline(timeoutMs), failures: unknown[] = []; + try { remove(['rm', '--force', keeper], ['container', 'inspect', keeper], remaining, 'task keeper', allocationId); } + catch (error) { failures.push(error); } + for (const volume of volumes) { + try { remove(['volume', 'rm', '--force', volume], ['volume', 'inspect', volume], remaining, 'task volume', allocationId); } + catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, 'Task filesystem cleanup did not settle.'); +}; + +export function assertTaskFilesystems(filesystems: TaskFilesystems, clone?: TaskClone): void { + const identity = allocations.get(filesystems); + if (!identity) throw new Error('Task filesystems were not created by the trusted allocator.'); + const { limits } = identity; + if (filesystems.workBytes !== limits.workBytes || filesystems.workInodes !== limits.workInodes + || filesystems.metadataBytes !== limits.metadataBytes || filesystems.metadataInodes !== limits.metadataInodes) + throw new Error('Task filesystem limits changed after allocation.'); + if (clone && (clone.id !== identity.clone.id || clone.taskId !== identity.clone.taskId + || realpathSync(clone.directory) !== identity.clone.directory || clone.head !== identity.clone.head)) + throw new Error('Task filesystems do not belong to the invocation clone.'); +} + +export function taskFilesystemAllocationId(filesystems: TaskFilesystems): string { + assertTaskFilesystems(filesystems); + return allocations.get(filesystems)!.allocationId; +} + +/** Allocate bounded, engine-owned task filesystems and keep them mounted. */ +export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimits, + imageId: string, timeoutMs = 60_000): TaskFilesystems { + for (const [name, value] of Object.entries(limits)) validLimit(value, name); + if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); + assertBuiltAgentImage(imageId); + const remaining = createDeadline(timeoutMs), staging = realpathSync(clone.directory); + if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); + if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); + const allocationId = randomUUID(); + const workVolume = `codeboost-work-${randomUUID()}`, metadataVolume = `codeboost-metadata-${randomUUID()}`; + const keeper = `codeboost-keeper-${randomUUID()}`, createdVolumes: string[] = []; + try { + for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes], + ['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) { + docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', + '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, + '--label', `io.codeboost.task-storage=${kind}`, '--label', `io.codeboost.allocation=${allocationId}`, name], remaining()); + createdVolumes.push(name); + } + const seed = ['set -eu', 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/', + 'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/', 'rm -rf /work/.git', 'mkdir /work/.git', + 'chown -R 10001:10001 /work /metadata'].join('; '); + docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', + '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, + '--label', 'io.codeboost.task-storage=keeper', '--label', `io.codeboost.allocation=${allocationId}`, + '--entrypoint', 'sleep', imageId, 'infinity'], remaining()); + docker(['run', '--rm', '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN', + '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32', + '--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, + '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, + '--entrypoint', 'sh', imageId, '-c', seed], remaining()); + remaining(); + const filesystems = Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); + allocations.set(filesystems, Object.freeze({ allocationId, + clone: Object.freeze({ ...clone, directory: staging }), limits: Object.freeze({ ...limits }) })); + return filesystems; + } catch (error) { + try { cleanup(keeper, createdVolumes.reverse(), allocationId); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Task allocation failed and cleanup did not settle.'); } + throw error; + } +} + +export function removeTaskFilesystems(filesystems: TaskFilesystems): void { + assertTaskFilesystems(filesystems); + const allocationId = taskFilesystemAllocationId(filesystems); + cleanup(filesystems.keeper, [filesystems.metadataVolume, filesystems.workVolume], allocationId); + allocations.delete(filesystems); +} diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index e5bd020..a006bd5 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -31,7 +31,7 @@ function fixture() { writeFileSync(join(input, 'schema.json'), '{"probe":"codeboost-schema-marker"}\n'); chmodSync(join(input, 'schema.json'), 0o444); chmodSync(input, 0o555); const clone = createTaskClone({ source, parent: staging, taskId: 'task-1', head: git(source, 'rev-parse', 'HEAD') }); - const filesystems = prepareTaskFilesystems(clone.directory, { + const filesystems = prepareTaskFilesystems(clone, { workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, }, imageId); taskFilesystems.push(filesystems); @@ -68,7 +68,7 @@ afterAll(() => { chmodSync(join(root, 'input'), 0o700); rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } -}); +}, 120_000); describe('real Docker agent isolation', () => { it('runs read-only with no root capabilities, host paths, inherited secrets, or writable tools', () => { @@ -183,6 +183,15 @@ describe('real Docker agent isolation', () => { ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); expect(() => createValidatedContainer(forged)).toThrow('trusted profile builder'); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning'), + filesystems: { ...data.filesystems }, inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, + imageId })).toThrow('trusted allocator'); + + const other = fixture(); + expect(() => createContainerProfile({ invocation: invocation(other.clone, 'planning'), + filesystems: data.filesystems, inputDirectory: other.input, command: ['true'], codexAuthFile: other.fakeAuth, + imageId })).toThrow('do not belong to the invocation clone'); + writeFileSync(data.fakeAuth, '{"changed":true}'); expect(valid.codexAuthFile).not.toBe(data.fakeAuth); expect(readFileSync(valid.codexAuthFile!, 'utf8')).toBe('{}'); @@ -216,6 +225,20 @@ describe('real Docker agent isolation', () => { } }, 60_000); + it('does not remove an active container when a duplicate attempt name collides', () => { + const data = fixture(), captured = invocation(data.clone, 'planning'); + const first = createContainerProfile({ invocation: captured, filesystems: data.filesystems, + inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId }); + const duplicate = createContainerProfile({ invocation: captured, filesystems: data.filesystems, + inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId }); + profiles.push(first, duplicate); + docker(...first.args); containers.add(first.name); + expect(() => createValidatedContainer(duplicate)).toThrow(); + const state = JSON.parse(docker('container', 'inspect', first.name))[0] as { State: { Status: string } }; + expect(state.State.Status).toBe('created'); + docker('rm', '--force', first.name); containers.delete(first.name); + }, 60_000); + it('rejects added capabilities and conflicting or duplicate filesystem options', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const imageIndex = valid.args.indexOf(imageId); @@ -250,10 +273,10 @@ describe('real Docker agent isolation', () => { expect(valid.args).not.toContain(AGENT_IMAGE); const untrustedDigest = `sha256:${'0'.repeat(64)}`; expect(() => assertBuiltAgentImage(untrustedDigest)).toThrow('trusted validated builder'); - expect(() => prepareTaskFilesystems(data.clone.directory, { + expect(() => prepareTaskFilesystems(data.clone, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, untrustedDigest)).toThrow('trusted validated builder'); - expect(() => prepareTaskFilesystems(data.clone.directory, { + expect(() => prepareTaskFilesystems(data.clone, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, AGENT_IMAGE)).toThrow('immutable built image ID'); }); From abc9994ea7e7b66d94e2a27d25d3452b190a0f65 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:44:24 -0700 Subject: [PATCH 07/17] Close D2 namespace and cleanup gaps --- agents/container/profile.ts | 2 +- agents/container/run.ts | 8 ++++++-- agents/container/storage.ts | 21 +++++++++++++-------- test/agent-container.test.ts | 6 ++++++ 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index d6a5d5f..a48da4b 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -96,8 +96,8 @@ export function assertContainerProfile(profile: ContainerProfile): void { export function disposeContainerProfile(profile: ContainerProfile): void { const identity = identities.get(profile); if (!identity) return; - identities.delete(profile); if (identity.cleanupDirectory) rmSync(identity.cleanupDirectory, { recursive: true, force: true }); + identities.delete(profile); } const safeName = (value: string) => { diff --git a/agents/container/run.ts b/agents/container/run.ts index 7ae6fb8..0f3aa7b 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -78,7 +78,8 @@ type Inspect = { WorkingDir: string; Labels: Record | null }; HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; CapAdd: string[] | null; - NetworkMode: string; PidMode: string; IpcMode: string; PidsLimit: number; Memory: number; NanoCpus: number; + NetworkMode: string; PidMode: string; IpcMode: string; UTSMode: string; UsernsMode: string; CgroupnsMode: string; + PidsLimit: number; Memory: number; NanoCpus: number; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; @@ -111,6 +112,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0 || !exactNoNewPrivileges(host.SecurityOpt) || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' + || host.UTSMode !== '' || host.UsernsMode !== '' || host.CgroupnsMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) throw new Error('Container daemon configuration is missing required lockdown.'); @@ -137,6 +139,7 @@ export function validateContainer(container: string, profile: ContainerProfile, const requestedMounts = new Map((host.Mounts ?? []).map(item => [item.Target, item])); const requestedInput = requestedMounts.get('/run/codeboost-input'); if (requestedInput?.Type !== 'bind' || canonicalDockerBindSource(requestedInput.Source) !== profile.inputDirectory + || canonicalDockerBindSource(input.Source) !== profile.inputDirectory || !requestedInput.ReadOnly) throw new Error('Schema input mount identity changed.'); if (work.Name !== profile.filesystems.workVolume || metadata.Name !== profile.filesystems.metadataVolume) throw new Error('Container task volumes do not match their captured identity.'); @@ -177,7 +180,8 @@ export function validateContainer(container: string, profile: ContainerProfile, if (profile.vendor === 'codex' && (auth?.Type !== 'bind' || auth.RW)) throw new Error('Codex auth must be a read-only file mount.'); const requestedAuth = requestedMounts.get('/run/codeboost-auth/codex/auth.json'); if (profile.vendor === 'codex' && (requestedAuth?.Type !== 'bind' - || canonicalDockerBindSource(requestedAuth.Source) !== profile.codexAuthFile || !requestedAuth.ReadOnly)) + || canonicalDockerBindSource(requestedAuth.Source) !== profile.codexAuthFile + || canonicalDockerBindSource(auth!.Source) !== profile.codexAuthFile || !requestedAuth.ReadOnly)) throw new Error('Codex auth mount identity changed.'); if (profile.vendor === 'claude' && auth) throw new Error('Claude profile must not mount Codex auth.'); if (inspect.Config.Env.some(value => value.indexOf('=') < 1)) throw new Error('Container environment is malformed.'); diff --git a/agents/container/storage.ts b/agents/container/storage.ts index 7e5e8fe..ede1316 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -64,10 +64,13 @@ const remove = (args: readonly string[], inspectArgs: readonly string[], remaini env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); if (!absent(inspect)) throw new Error(`Failed to confirm removal of ${kind}.`); }; -const cleanup = (keeper: string, volumes: readonly string[], allocationId: string, timeoutMs = 30_000) => { +const cleanup = (containers: readonly string[], volumes: readonly string[], allocationId: string, timeoutMs = 30_000) => { const remaining = createDeadline(timeoutMs), failures: unknown[] = []; - try { remove(['rm', '--force', keeper], ['container', 'inspect', keeper], remaining, 'task keeper', allocationId); } - catch (error) { failures.push(error); } + for (const container of containers) { + try { remove(['rm', '--force', container], ['container', 'inspect', container], remaining, + 'task container', allocationId); } + catch (error) { failures.push(error); } + } for (const volume of volumes) { try { remove(['volume', 'rm', '--force', volume], ['volume', 'inspect', volume], remaining, 'task volume', allocationId); } catch (error) { failures.push(error); } @@ -103,14 +106,15 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); const allocationId = randomUUID(); const workVolume = `codeboost-work-${randomUUID()}`, metadataVolume = `codeboost-metadata-${randomUUID()}`; - const keeper = `codeboost-keeper-${randomUUID()}`, createdVolumes: string[] = []; + const keeper = `codeboost-keeper-${randomUUID()}`, seeder = `codeboost-seeder-${randomUUID()}`; + const createdVolumes: string[] = []; try { for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes], ['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) { + createdVolumes.push(name); docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, '--label', `io.codeboost.task-storage=${kind}`, '--label', `io.codeboost.allocation=${allocationId}`, name], remaining()); - createdVolumes.push(name); } const seed = ['set -eu', 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/', 'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/', 'rm -rf /work/.git', 'mkdir /work/.git', @@ -120,7 +124,8 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, '--label', 'io.codeboost.task-storage=keeper', '--label', `io.codeboost.allocation=${allocationId}`, '--entrypoint', 'sleep', imageId, 'infinity'], remaining()); - docker(['run', '--rm', '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN', + docker(['run', '--rm', '--name', seeder, '--label', `io.codeboost.allocation=${allocationId}`, + '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN', '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, @@ -131,7 +136,7 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi clone: Object.freeze({ ...clone, directory: staging }), limits: Object.freeze({ ...limits }) })); return filesystems; } catch (error) { - try { cleanup(keeper, createdVolumes.reverse(), allocationId); } + try { cleanup([seeder, keeper], createdVolumes.reverse(), allocationId); } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Task allocation failed and cleanup did not settle.'); } throw error; } @@ -140,6 +145,6 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi export function removeTaskFilesystems(filesystems: TaskFilesystems): void { assertTaskFilesystems(filesystems); const allocationId = taskFilesystemAllocationId(filesystems); - cleanup(filesystems.keeper, [filesystems.metadataVolume, filesystems.workVolume], allocationId); + cleanup([filesystems.keeper], [filesystems.metadataVolume, filesystems.workVolume], allocationId); allocations.delete(filesystems); } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index a006bd5..0dfe403 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -264,6 +264,12 @@ describe('real Docker agent isolation', () => { const state = JSON.parse(docker('container', 'inspect', valid.name))[0] as { State: { Status: string } }; expect(state.State.Status).toBe('created'); docker('rm', '--force', valid.name); containers.delete(valid.name); + + const imageIndex = valid.args.indexOf(imageId); + const namespaceArgs = [...valid.args.slice(0, imageIndex), '--uts=host', ...valid.args.slice(imageIndex)]; + docker(...namespaceArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); it('creates containers from the captured immutable image rather than its mutable tag', () => { From b701213769d3e020d5967dd0cf33b0d445d2dd7d Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:46:19 -0700 Subject: [PATCH 08/17] Allow clone ownership regression to settle --- test/agent-container.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 0dfe403..e930ad8 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -201,7 +201,7 @@ describe('real Docker agent isolation', () => { chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); expect(() => createValidatedContainer(valid)).toThrow('only one bounded'); chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); - }); + }, 60_000); it('rejects extra security policies and environment paths that can escape bounded storage', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); From cc60cb9ea65bf53778e2c3b124afdb51fb867848 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 09:51:42 -0700 Subject: [PATCH 09/17] Pin the D2 Codex state path --- agents/container/run.ts | 4 +++- test/agent-container.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/agents/container/run.ts b/agents/container/run.ts index 0f3aa7b..365b00f 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -203,7 +203,9 @@ export function validateContainer(container: string, profile: ContainerProfile, || environment.get('npm_config_cache') !== '/tmp/npm-cache' || environment.get('XDG_CACHE_HOME') !== '/tmp/xdg-cache') throw new Error('Container isolation environment changed.'); - if (profile.vendor === 'codex' && names.includes('CLAUDE_CODE_OAUTH_TOKEN')) throw new Error('Credential profiles must not be combined.'); + if (profile.vendor === 'codex' && (names.includes('CLAUDE_CODE_OAUTH_TOKEN') + || environment.get('CODEX_HOME') !== '/run/codeboost-auth/codex')) + throw new Error('Credential profiles must not be combined or redirected.'); if (profile.vendor === 'claude' && (names.includes('CODEX_HOME') || !names.includes('CLAUDE_CODE_OAUTH_TOKEN'))) throw new Error('Credential profiles must not be combined.'); assertContainerProfile(profile); diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index e930ad8..2ad4a5c 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -217,10 +217,10 @@ describe('real Docker agent isolation', () => { expect(() => validateContainer(valid.name, valid)).toThrow(/environment|PATH/); docker('rm', '--force', valid.name); containers.delete(valid.name); - for (const changedCache of ['npm_config_cache=/work/npm-cache', 'XDG_CACHE_HOME=/work/xdg-cache']) { - const cacheArgs = [...valid.args.slice(0, imageIndex), '--env', changedCache, ...valid.args.slice(imageIndex)]; - docker(...cacheArgs); containers.add(valid.name); - expect(() => validateContainer(valid.name, valid)).toThrow('isolation environment'); + for (const changedPath of ['npm_config_cache=/work/npm-cache', 'XDG_CACHE_HOME=/work/xdg-cache', 'CODEX_HOME=/work']) { + const changedArgs = [...valid.args.slice(0, imageIndex), '--env', changedPath, ...valid.args.slice(imageIndex)]; + docker(...changedArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow(/isolation environment|Credential profiles/); docker('rm', '--force', valid.name); containers.delete(valid.name); } }, 60_000); From f27ef2d93acb435283e8c9f6b17b16c7766d4e91 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 10:10:48 -0700 Subject: [PATCH 10/17] Seal D2 inputs and complete resource checks --- agents/container/profile.ts | 125 ++++++++++++++++++++++------------- agents/container/run.ts | 18 ++++- agents/container/storage.ts | 2 + git/clone.ts | 10 ++- test/agent-container.test.ts | 20 ++++-- 5 files changed, 122 insertions(+), 53 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index a48da4b..047c926 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; -import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, +import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -39,18 +39,40 @@ interface FileIdentity { readonly digest: string; } interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity; - readonly cleanupDirectory?: string; readonly filesystems: TaskFilesystems; readonly clone: InvocationInput['clone'] } + readonly cleanupDirectories: readonly string[]; readonly filesystems: TaskFilesystems; + readonly clone: InvocationInput['clone'] } type InputIdentity = Pick; +interface InputCapture extends InputIdentity { readonly content: Buffer } const identities = new WeakMap(); +const removeOwnedDirectory = (directory: string) => { + if (!lstatSync(directory, { throwIfNoEntry: false })) return; + chmodSync(directory, 0o700); + rmSync(directory, { recursive: true, force: true }); +}; +const removeOwnedDirectories = (directories: readonly string[]) => { + const failures: unknown[] = []; + for (const directory of directories) { + try { removeOwnedDirectory(directory); } catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, 'Profile snapshot cleanup did not settle.'); +}; const readCapturedFile = (path: string, kind: string): { identity: FileIdentity; content: Buffer } => { let fd: number | undefined; try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); const before = fstatSync(fd); - if (!before.isFile() || before.nlink !== 1 || before.size > 1024 * 1024) + const maximum = 1024 * 1024; + if (!before.isFile() || before.nlink !== 1 || before.size > maximum) throw new Error(`${kind} must be a bounded, unlinked regular file.`); - const content = readFileSync(fd); + const bounded = Buffer.allocUnsafe(maximum + 1); + let length = 0, count = 0; + do { + count = readSync(fd, bounded, length, bounded.length - length, null); + length += count; + } while (count > 0 && length < bounded.length); + if (length > maximum) throw new Error(`${kind} exceeds its maximum size.`); + const content = bounded.subarray(0, length); const after = fstatSync(fd); if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) @@ -65,7 +87,7 @@ const sameFile = (actual: FileIdentity, expected: FileIdentity) => actual.path = && actual.dev === expected.dev && actual.ino === expected.ino && actual.mode === expected.mode && actual.nlink === expected.nlink && actual.size === expected.size && actual.mtimeMs === expected.mtimeMs && actual.digest === expected.digest; -const captureInput = (directory: string): InputIdentity => { +const captureInput = (directory: string): InputCapture => { const stat = lstatSync(directory); if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be a container-readable real directory.'); @@ -73,9 +95,9 @@ const captureInput = (directory: string): InputIdentity => { const entries = readdirSync(canonical); if (entries.length !== 1 || entries[0] !== 'schema.json') throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.'); - const schema = captureFile(`${canonical}/schema.json`, 'Schema input'); + const captured = readCapturedFile(`${canonical}/schema.json`, 'Schema input'), schema = captured.identity; if ((schema.mode & 0o004) === 0) throw new Error('Schema input must be container-readable.'); - return Object.freeze({ inputDirectory: canonical, schema }); + return Object.freeze({ inputDirectory: canonical, schema, content: captured.content }); }; /** Internal authenticity and host-file revalidation used at every launch boundary. */ @@ -96,7 +118,7 @@ export function assertContainerProfile(profile: ContainerProfile): void { export function disposeContainerProfile(profile: ContainerProfile): void { const identity = identities.get(profile); if (!identity) return; - if (identity.cleanupDirectory) rmSync(identity.cleanupDirectory, { recursive: true, force: true }); + removeOwnedDirectories(identity.cleanupDirectories); identities.delete(profile); } @@ -119,8 +141,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil throw new Error('Container profile requires the immutable built image ID.'); assertBuiltAgentImage(options.imageId); assertTaskFilesystems(filesystems, invocation.clone); - const inputIdentity = captureInput(options.inputDirectory); - const inputDirectory = inputIdentity.inputDirectory; + const sourceInput = captureInput(options.inputDirectory); if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) throw new Error('Codex requires only its auth file.'); if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile)) @@ -132,47 +153,59 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) throw new Error('Codex auth must be a direct regular file, not a link.'); const sourceAuth = options.codexAuthFile ? readCapturedFile(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; - let cleanupDirectory: string | undefined, codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined; - if (sourceAuth) { - cleanupDirectory = mkdtempSync(join(tmpdir(), 'codeboost-auth-')); - try { + const cleanupDirectories: string[] = []; + let codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined; + try { + const inputDirectory = mkdtempSync(join(tmpdir(), 'codeboost-input-')); + cleanupDirectories.push(inputDirectory); + writeFileSync(join(inputDirectory, 'schema.json'), sourceInput.content, + { mode: 0o400, flag: 'wx' }); + chmodSync(join(inputDirectory, 'schema.json'), 0o444); + chmodSync(inputDirectory, 0o555); + const inputIdentity = captureInput(inputDirectory); + if (sourceAuth) { + const cleanupDirectory = mkdtempSync(join(tmpdir(), 'codeboost-auth-')); + cleanupDirectories.push(cleanupDirectory); const stagedAuth = join(cleanupDirectory, 'auth.json'); writeFileSync(stagedAuth, sourceAuth.content, { mode: 0o400, flag: 'wx' }); chmodSync(stagedAuth, 0o444); codexAuthFile = mountSource(realpathSync(stagedAuth), 'Codex auth'); authIdentity = captureFile(codexAuthFile, 'Staged Codex auth'); - } catch (error) { - rmSync(cleanupDirectory, { recursive: true, force: true }); - throw error; } + const name = `codeboost-agent-${safeName(invocation.attemptId)}`, ownershipId = randomUUID(); + const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); + const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', + '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', + '--cpus=1', '--shm-size=16m', + '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, + '--label', `io.codeboost.invocation=${ownershipId}`, + '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', + '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, + '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, + '--env', 'XDG_CACHE_HOME=/tmp/xdg-cache', + '--tmpfs', '/tmp:rw,nosuid,nodev,size=33554432,nr_inodes=4096,mode=1777', + '--tmpfs', '/home/codeboost:rw,nosuid,nodev,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', + '--mount', mount({ type: 'volume', source: filesystems.workVolume, target: '/work', readonly: readOnlyWork }), + '--mount', mount({ type: 'volume', source: filesystems.metadataVolume, target: '/work/.git', readonly: true }), + '--mount', mount({ type: 'bind', source: inputIdentity.inputDirectory, target: '/run/codeboost-input', readonly: true })]; + if (invocation.vendor === 'codex') { + args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', + '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', + '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); + } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); + args.push(options.imageId, ...options.command); + const capturedFilesystems = filesystems; + const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, + phase: invocation.phase, vendor: invocation.vendor, + filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile, + command: Object.freeze([...options.command]), ownershipId }); + identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema, + auth: authIdentity, + cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone })); + return profile; + } catch (error) { + try { removeOwnedDirectories(cleanupDirectories); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Profile creation and cleanup both failed.'); } + throw error; } - const name = `codeboost-agent-${safeName(invocation.attemptId)}`, ownershipId = randomUUID(); - const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); - const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', - '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1', - '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, - '--label', `io.codeboost.invocation=${ownershipId}`, - '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', - '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, - '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, - '--env', 'XDG_CACHE_HOME=/tmp/xdg-cache', - '--tmpfs', '/tmp:rw,nosuid,nodev,size=33554432,nr_inodes=4096,mode=1777', - '--tmpfs', '/home/codeboost:rw,nosuid,nodev,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', - '--mount', mount({ type: 'volume', source: filesystems.workVolume, target: '/work', readonly: readOnlyWork }), - '--mount', mount({ type: 'volume', source: filesystems.metadataVolume, target: '/work/.git', readonly: true }), - '--mount', mount({ type: 'bind', source: inputDirectory, target: '/run/codeboost-input', readonly: true })]; - if (invocation.vendor === 'codex') { - args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', - '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', - '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); - } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); - args.push(options.imageId, ...options.command); - const capturedFilesystems = filesystems; - const profile = Object.freeze({ name, args: Object.freeze(args), expectedImage: options.imageId, - phase: invocation.phase, vendor: invocation.vendor, - filesystems: capturedFilesystems, inputDirectory, codexAuthFile, - command: Object.freeze([...options.command]), ownershipId }); - identities.set(profile, Object.freeze({ ...inputIdentity, auth: authIdentity, cleanupDirectory, - filesystems, clone: invocation.clone })); - return profile; } diff --git a/agents/container/run.ts b/agents/container/run.ts index 365b00f..c837856 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -79,7 +79,13 @@ type Inspect = { HostConfig: { ReadonlyRootfs: boolean; Privileged: boolean; CapDrop: string[] | null; SecurityOpt: string[] | null; CapAdd: string[] | null; NetworkMode: string; PidMode: string; IpcMode: string; UTSMode: string; UsernsMode: string; CgroupnsMode: string; - PidsLimit: number; Memory: number; NanoCpus: number; + PidsLimit: number; Memory: number; MemorySwap: number; MemoryReservation: number; MemorySwappiness: number | null; + OomKillDisable: boolean; OomScoreAdj: number; NanoCpus: number; CpuShares: number; CpuPeriod: number; CpuQuota: number; + CpuRealtimePeriod: number; CpuRealtimeRuntime: number; CpusetCpus: string; CpusetMems: string; ShmSize: number; + BlkioWeight: number; BlkioWeightDevice: unknown[]; BlkioDeviceReadBps: unknown[]; BlkioDeviceWriteBps: unknown[]; + BlkioDeviceReadIOps: unknown[]; BlkioDeviceWriteIOps: unknown[]; Ulimits: unknown[]; CpuCount: number; + CpuPercent: number; IOMaximumBandwidth: number; IOMaximumIOps: number; DeviceCgroupRules: unknown[] | null; + StorageOpt?: Record | null; CgroupParent: string; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; @@ -114,7 +120,15 @@ export function validateContainer(container: string, profile: ContainerProfile, || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' || host.UTSMode !== '' || host.UsernsMode !== '' || host.CgroupnsMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 - || host.Memory !== 512 * 1024 * 1024 || host.NanoCpus !== 1_000_000_000) + || host.Memory !== 512 * 1024 * 1024 || host.MemorySwap !== 512 * 1024 * 1024 + || host.MemoryReservation !== 0 || host.MemorySwappiness !== null || host.OomKillDisable || host.OomScoreAdj !== 0 + || host.NanoCpus !== 1_000_000_000 || host.CpuShares !== 0 || host.CpuPeriod !== 0 || host.CpuQuota !== 0 + || host.CpuRealtimePeriod !== 0 || host.CpuRealtimeRuntime !== 0 || host.CpusetCpus !== '' || host.CpusetMems !== '' + || host.ShmSize !== 16 * 1024 * 1024 || host.BlkioWeight !== 0 + || host.BlkioWeightDevice.length || host.BlkioDeviceReadBps.length || host.BlkioDeviceWriteBps.length + || host.BlkioDeviceReadIOps.length || host.BlkioDeviceWriteIOps.length || host.Ulimits.length + || host.CpuCount !== 0 || host.CpuPercent !== 0 || host.IOMaximumBandwidth !== 0 || host.IOMaximumIOps !== 0 + || host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== '') throw new Error('Container daemon configuration is missing required lockdown.'); const tmpfs = host.Tmpfs ?? {}; const expectedTmpfs = new Map([ diff --git a/agents/container/storage.ts b/agents/container/storage.ts index ede1316..6990a59 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; import type { TaskClone } from '../contract.ts'; +import { assertTaskClone } from '../../git/clone.ts'; import { assertBuiltAgentImage } from './image.ts'; export interface TaskFilesystems { @@ -101,6 +102,7 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi for (const [name, value] of Object.entries(limits)) validLimit(value, name); if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); assertBuiltAgentImage(imageId); + assertTaskClone(clone); const remaining = createDeadline(timeoutMs), staging = realpathSync(clone.directory); if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); diff --git a/git/clone.ts b/git/clone.ts index cc9a0ca..879080f 100644 --- a/git/clone.ts +++ b/git/clone.ts @@ -4,6 +4,12 @@ import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node: import { isAbsolute, join, relative, resolve } from 'node:path'; import type { TaskClone } from '../agents/contract.ts'; +const trustedClones = new WeakSet(); + +export function assertTaskClone(clone: TaskClone): void { + if (!trustedClones.has(clone)) throw new Error('Task clone was not created by the trusted clone builder.'); +} + /** * Prepare an independent committed snapshot. This is trusted staging, not the * writable execution filesystem: D2 must reserve bounded storage and separate @@ -79,7 +85,9 @@ export function createTaskClone(options: { run(directory, 'remote', 'remove', 'origin'); run(directory, 'checkout', '--detach', options.head); if (run(directory, 'rev-parse', 'HEAD') !== options.head) throw new Error('Task head changed during clone.'); - return Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head }); + const clone = Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head }); + trustedClones.add(clone); + return clone; } catch (error) { rmSync(directory, { recursive: true, force: true }); throw error; diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 2ad4a5c..6264eee 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -176,8 +176,9 @@ describe('real Docker agent isolation', () => { } finally { spawnSync('docker', ['volume', 'rm', '--force', rogue], { stdio: 'ignore' }); } }, 60_000); - it('rejects cloned profiles and host inputs changed after capture', () => { - const data = fixture(), valid = profile(data, 'planning', ['true']); + it('rejects cloned profiles while sealed snapshots ignore later host changes', () => { + const data = fixture(), valid = profile(data, 'planning', ['sh', '-c', + 'set -eu; grep -q codeboost-schema-marker /run/codeboost-input/schema.json; test ! -e /run/codeboost-input/extra.json']); const forged = Object.freeze({ ...valid, inputDirectory: '/', args: Object.freeze(valid.args.map(value => value.includes(`source=${data.input},`) ? value.replace(`source=${data.input},`, 'source=/,') : value)) }); @@ -198,8 +199,11 @@ describe('real Docker agent isolation', () => { expect(statSync(valid.codexAuthFile!).mode & 0o777).toBe(0o444); writeFileSync(data.fakeAuth, '{}'); - chmodSync(data.input, 0o755); writeFileSync(join(data.input, 'extra.json'), '{}'); chmodSync(data.input, 0o555); - expect(() => createValidatedContainer(valid)).toThrow('only one bounded'); + chmodSync(data.input, 0o755); chmodSync(join(data.input, 'schema.json'), 0o644); + writeFileSync(join(data.input, 'schema.json'), '{"probe":"changed"}\n'); + writeFileSync(join(data.input, 'extra.json'), '{}'); + chmodSync(join(data.input, 'schema.json'), 0o444); chmodSync(data.input, 0o555); + expect(runContainer(valid)).toBe(''); chmodSync(data.input, 0o755); rmSync(join(data.input, 'extra.json')); chmodSync(data.input, 0o555); }, 60_000); @@ -270,6 +274,11 @@ describe('real Docker agent isolation', () => { docker(...namespaceArgs); containers.add(valid.name); expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); docker('rm', '--force', valid.name); containers.delete(valid.name); + + const resourceArgs = [...valid.args.slice(0, imageIndex), '--memory-swap=-1', ...valid.args.slice(imageIndex)]; + docker(...resourceArgs); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + docker('rm', '--force', valid.name); containers.delete(valid.name); }, 60_000); it('creates containers from the captured immutable image rather than its mutable tag', () => { @@ -285,6 +294,9 @@ describe('real Docker agent isolation', () => { expect(() => prepareTaskFilesystems(data.clone, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, AGENT_IMAGE)).toThrow('immutable built image ID'); + expect(() => prepareTaskFilesystems({ ...data.clone }, { + workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, + }, imageId)).toThrow('trusted clone builder'); }); if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { From af3c893497ca58005a721f882d562f15077e484f Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 23:00:22 -0700 Subject: [PATCH 11/17] 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 --- .github/workflows/agent-isolation.yml | 7 +++++- agents/container/probe.sh | 4 ++- agents/container/profile.ts | 2 +- agents/container/run.ts | 9 ++++--- agents/container/storage.ts | 7 ++++-- test/agent-container.test.ts | 35 ++++++++++++++++++++++++--- 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index b568c04..0ac896d 100644 --- a/.github/workflows/agent-isolation.yml +++ b/.github/workflows/agent-isolation.yml @@ -1,7 +1,12 @@ name: Agent isolation on: push: - branches: ['codex/agent-isolation-d2'] + branches: [main] + paths: + - 'agents/**' + - 'git/clone.ts' + - 'test/agent-*.test.ts' + - '.github/workflows/agent-isolation.yml' pull_request: paths: - 'agents/**' diff --git a/agents/container/probe.sh b/agents/container/probe.sh index df56239..a10033e 100644 --- a/agents/container/probe.sh +++ b/agents/container/probe.sh @@ -10,7 +10,9 @@ require_option() { has_option "$(mount_options "$1")" "$2" || fail "$1 must be m filesystem_bytes() { df -B1 --output=size "$1" | tail -n 1 | tr -d ' '; } filesystem_inodes() { df --output=itotal "$1" | tail -n 1 | tr -d ' '; } require_ceiling() { - [ "$(filesystem_bytes "$1")" -le "$2" ] || fail "$1 exceeds its byte limit" + # tmpfs rounds size= up to a whole page, so compare against the page-rounded limit. + page=$(getconf PAGESIZE) + [ "$(filesystem_bytes "$1")" -le "$(( ($2 + page - 1) / page * page ))" ] || fail "$1 exceeds its byte limit" [ "$(filesystem_inodes "$1")" -le "$3" ] || fail "$1 exceeds its inode limit" } diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 047c926..74a0255 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -176,7 +176,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', - '--cpus=1', '--shm-size=16m', + '--cpus=1', '--shm-size=16m', '--ipc=private', '--cgroupns=private', '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, '--label', `io.codeboost.invocation=${ownershipId}`, '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', diff --git a/agents/container/run.ts b/agents/container/run.ts index c837856..de8d329 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -82,8 +82,9 @@ type Inspect = { PidsLimit: number; Memory: number; MemorySwap: number; MemoryReservation: number; MemorySwappiness: number | null; OomKillDisable: boolean; OomScoreAdj: number; NanoCpus: number; CpuShares: number; CpuPeriod: number; CpuQuota: number; CpuRealtimePeriod: number; CpuRealtimeRuntime: number; CpusetCpus: string; CpusetMems: string; ShmSize: number; - BlkioWeight: number; BlkioWeightDevice: unknown[]; BlkioDeviceReadBps: unknown[]; BlkioDeviceWriteBps: unknown[]; - BlkioDeviceReadIOps: unknown[]; BlkioDeviceWriteIOps: unknown[]; Ulimits: unknown[]; CpuCount: number; + BlkioWeight: number; BlkioWeightDevice: unknown[] | null; BlkioDeviceReadBps: unknown[] | null; + BlkioDeviceWriteBps: unknown[] | null; BlkioDeviceReadIOps: unknown[] | null; BlkioDeviceWriteIOps: unknown[] | null; + Ulimits: unknown[] | null; CpuCount: number; CpuPercent: number; IOMaximumBandwidth: number; IOMaximumIOps: number; DeviceCgroupRules: unknown[] | null; StorageOpt?: Record | null; CgroupParent: string; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; @@ -125,8 +126,8 @@ export function validateContainer(container: string, profile: ContainerProfile, || host.NanoCpus !== 1_000_000_000 || host.CpuShares !== 0 || host.CpuPeriod !== 0 || host.CpuQuota !== 0 || host.CpuRealtimePeriod !== 0 || host.CpuRealtimeRuntime !== 0 || host.CpusetCpus !== '' || host.CpusetMems !== '' || host.ShmSize !== 16 * 1024 * 1024 || host.BlkioWeight !== 0 - || host.BlkioWeightDevice.length || host.BlkioDeviceReadBps.length || host.BlkioDeviceWriteBps.length - || host.BlkioDeviceReadIOps.length || host.BlkioDeviceWriteIOps.length || host.Ulimits.length + || host.BlkioWeightDevice?.length || host.BlkioDeviceReadBps?.length || host.BlkioDeviceWriteBps?.length + || host.BlkioDeviceReadIOps?.length || host.BlkioDeviceWriteIOps?.length || host.Ulimits?.length || host.CpuCount !== 0 || host.CpuPercent !== 0 || host.IOMaximumBandwidth !== 0 || host.IOMaximumIOps !== 0 || host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== '') throw new Error('Container daemon configuration is missing required lockdown.'); diff --git a/agents/container/storage.ts b/agents/container/storage.ts index 6990a59..084f455 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -118,8 +118,11 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, '--label', `io.codeboost.task-storage=${kind}`, '--label', `io.codeboost.allocation=${allocationId}`, name], remaining()); } - const seed = ['set -eu', 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/. /work/', - 'cp -a --no-preserve=ownership,timestamps /work/.git/. /metadata/', 'rm -rf /work/.git', 'mkdir /work/.git', + // Copy metadata straight to its own volume so the work allocation never holds both at once. + const seed = ['set -eu', + 'find /run/codeboost-staging -mindepth 1 -maxdepth 1 ! -name .git' + + ' -exec cp -a --no-preserve=ownership,timestamps -t /work/ {} +', + 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/.git/. /metadata/', 'mkdir -p /work/.git', 'chown -R 10001:10001 /work /metadata'].join('; '); docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 6264eee..6859d4d 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -1,5 +1,5 @@ import { execFileSync, spawnSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -22,16 +22,22 @@ const docker = (...args: string[]) => execFileSync('docker', args, { encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], }).trim(); -function fixture() { +function fixture(options: { limits?: Parameters[1]; historyBytes?: number } = {}) { const root = mkdtempSync(join(tmpdir(), 'agent-container-')); roots.push(root); const source = join(root, 'source'), staging = join(root, 'staging'), input = join(root, 'input'); mkdirSync(source); mkdirSync(staging); mkdirSync(input); git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com'); - writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline'); + if (options.historyBytes) { + // Incompressible history that no longer exists in the checked-out worktree. + writeFileSync(join(source, 'history.bin'), randomBytes(options.historyBytes)); + git(source, 'add', '.'); git(source, 'commit', '-m', 'history'); + rmSync(join(source, 'history.bin')); + } + writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '-A'); git(source, 'commit', '-m', 'baseline'); writeFileSync(join(input, 'schema.json'), '{"probe":"codeboost-schema-marker"}\n'); chmodSync(join(input, 'schema.json'), 0o444); chmodSync(input, 0o555); const clone = createTaskClone({ source, parent: staging, taskId: 'task-1', head: git(source, 'rev-parse', 'HEAD') }); - const filesystems = prepareTaskFilesystems(clone, { + const filesystems = prepareTaskFilesystems(clone, options.limits ?? { workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, }, imageId); taskFilesystems.push(filesystems); @@ -89,6 +95,27 @@ describe('real Docker agent isolation', () => { } finally { delete process.env.HOST_SECRET_SENTINEL; } }, 60_000); + it('seeds Git history larger than the work allocation into the metadata volume only', () => { + const data = fixture({ historyBytes: 4 * 1024 * 1024, limits: { + workBytes: 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, + } }); + expect(runContainer(profile(data, 'review', ['sh', '-c', + 'set -eu; test ! -e /work/history.bin; git cat-file -e HEAD~1:history.bin; cat /work/file.txt']))).toBe('trusted'); + }, 60_000); + + it('accepts byte limits that tmpfs rounds up to a whole page', () => { + const data = fixture({ limits: { + workBytes: 16 * 1024 * 1024 + 1, workInodes: 512, metadataBytes: 16 * 1024 * 1024 + 1, metadataInodes: 512, + } }); + expect(runContainer(profile(data, 'execute', ['sh', '-c', 'printf rounded']))).toBe('rounded'); + }, 60_000); + + it('requests private IPC and cgroup namespaces instead of relying on daemon defaults', () => { + const args = profile(fixture(), 'planning', ['true']).args; + expect(args).toContain('--ipc=private'); + expect(args).toContain('--cgroupns=private'); + }, 60_000); + it('persists execution changes while replacing HOME and scratch for each invocation', () => { const data = fixture(); expect(runContainer(profile(data, 'execute', ['sh', '-c', From 65c32e35ed28bba32244c699c495f3dd27a95b63 Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 23:15:36 -0700 Subject: [PATCH 12/17] 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 --- agents/container/profile.ts | 11 +++++++---- agents/container/run.ts | 10 +++++----- test/agent-container.test.ts | 20 ++++++++++++++++++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 74a0255..906e40f 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -60,7 +60,11 @@ const removeOwnedDirectories = (directories: readonly string[]) => { const readCapturedFile = (path: string, kind: string): { identity: FileIdentity; content: Buffer } => { let fd: number | undefined; try { - fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') throw new Error(`${kind} must be a direct regular file, not a link.`); + throw error; + } const before = fstatSync(fd); const maximum = 1024 * 1024; if (!before.isFile() || before.nlink !== 1 || before.size > maximum) @@ -150,9 +154,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil if (!/^codeboost-work-[0-9a-f-]+$/.test(filesystems.workVolume) || !/^codeboost-metadata-[0-9a-f-]+$/.test(filesystems.metadataVolume) || !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.'); - if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) - throw new Error('Codex auth must be a direct regular file, not a link.'); - const sourceAuth = options.codexAuthFile ? readCapturedFile(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; + // Read through one no-follow descriptor so the path cannot be swapped between check and open. + const sourceAuth = options.codexAuthFile ? readCapturedFile(options.codexAuthFile, 'Codex auth') : undefined; const cleanupDirectories: string[] = []; let codexAuthFile: string | undefined, authIdentity: FileIdentity | undefined; try { diff --git a/agents/container/run.ts b/agents/container/run.ts index de8d329..ae76046 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -54,10 +54,8 @@ const removeContainerOrThrow = (profile: ContainerProfile) => { return; } const inspected = JSON.parse(before.stdout || '[]')[0] as { Config?: { Labels?: Record } } | undefined; - if (inspected?.Config?.Labels?.['io.codeboost.invocation'] !== profile.ownershipId) { - disposeContainerProfile(profile); - return; - } + if (inspected?.Config?.Labels?.['io.codeboost.invocation'] !== profile.ownershipId) + throw new Error('Agent container name is held by another invocation; staged credentials were retained.'); const result = spawnSync('docker', ['rm', '--force', profile.name], { encoding: 'utf8', timeout: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], }); @@ -87,6 +85,7 @@ type Inspect = { Ulimits: unknown[] | null; CpuCount: number; CpuPercent: number; IOMaximumBandwidth: number; IOMaximumIOps: number; DeviceCgroupRules: unknown[] | null; StorageOpt?: Record | null; CgroupParent: string; + RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; @@ -129,7 +128,8 @@ export function validateContainer(container: string, profile: ContainerProfile, || host.BlkioWeightDevice?.length || host.BlkioDeviceReadBps?.length || host.BlkioDeviceWriteBps?.length || host.BlkioDeviceReadIOps?.length || host.BlkioDeviceWriteIOps?.length || host.Ulimits?.length || host.CpuCount !== 0 || host.CpuPercent !== 0 || host.IOMaximumBandwidth !== 0 || host.IOMaximumIOps !== 0 - || host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== '') + || host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== '' + || !['', 'no'].includes(host.RestartPolicy?.Name ?? '') || (host.RestartPolicy?.MaximumRetryCount ?? 0) !== 0) throw new Error('Container daemon configuration is missing required lockdown.'); const tmpfs = host.Tmpfs ?? {}; const expectedTmpfs = new Map([ diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 6859d4d..f97a3fb 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomBytes, randomUUID } from 'node:crypto'; -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -264,12 +264,28 @@ describe('real Docker agent isolation', () => { inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId }); profiles.push(first, duplicate); docker(...first.args); containers.add(first.name); - expect(() => createValidatedContainer(duplicate)).toThrow(); + expect(() => createValidatedContainer(duplicate)).toThrow('Container creation failed and cleanup did not settle.'); + expect(existsSync(duplicate.codexAuthFile!)).toBe(true); const state = JSON.parse(docker('container', 'inspect', first.name))[0] as { State: { Status: string } }; expect(state.State.Status).toBe('created'); docker('rm', '--force', first.name); containers.delete(first.name); }, 60_000); + it('refuses a Codex auth path that is a link without resolving it', () => { + const data = fixture(), link = join(data.root, 'auth-link.json'); + symlinkSync(data.fakeAuth, link); + expect(() => profile(data, 'planning', ['true'], { codexAuthFile: link })).toThrow('not a link'); + }, 60_000); + + it('rejects a restart policy that could relaunch the agent after it exits', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + const imageIndex = valid.args.indexOf(imageId); + docker(...valid.args.slice(0, imageIndex), '--restart=always', ...valid.args.slice(imageIndex)); + containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + }, 60_000); + it('rejects added capabilities and conflicting or duplicate filesystem options', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const imageIndex = valid.args.indexOf(imageId); From c61b3b5f8493355566dc711646f3799c3493e23f Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 23:22:23 -0700 Subject: [PATCH 13/17] 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 --- agents/container/run.ts | 43 +++++++++++++++++++++++++++--------- test/agent-container.test.ts | 16 +++++++++++++- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/agents/container/run.ts b/agents/container/run.ts index ae76046..01c5fe4 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -42,18 +42,30 @@ const canonicalDockerBindSource = (source: string) => { const desktopHostPath = source.startsWith('/host_mnt/') ? source.slice('/host_mnt'.length) : source; try { return realpathSync(desktopHostPath); } catch { return source; } }; -const removeContainerOrThrow = (profile: ContainerProfile) => { - const remaining = createDeadline(30_000); - const before = spawnSync('docker', ['container', 'inspect', profile.name], { - encoding: 'utf8', timeout: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], - }); - if (before.status !== 0) { +/** How long a killed `docker create` may still materialize its container in the daemon. */ +const CREATE_SETTLE_MS = 10_000; +const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +const removeContainerOrThrow = (profile: ContainerProfile, createUnsettled = false) => { + const remaining = createDeadline(30_000 + (createUnsettled ? CREATE_SETTLE_MS : 0)); + const settleBy = performance.now() + (createUnsettled ? CREATE_SETTLE_MS : 0); + let before: ReturnType; + for (;;) { + before = spawnSync('docker', ['container', 'inspect', profile.name], { + encoding: 'utf8', timeout: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + }); + if (before.status === 0) break; const missing = !before.error && /No such (?:object|container)/i.test(`${before.stdout ?? ''}\n${before.stderr ?? ''}`); if (!missing) throw new Error('Failed to establish ownership of the agent container; staged credentials were retained.'); - disposeContainerProfile(profile); - return; + if (!createUnsettled) { + disposeContainerProfile(profile); + return; + } + // A killed create may still land in the daemon; absence is not proof until the settle window passes. + if (performance.now() >= settleBy) + throw new Error('Agent container creation did not settle; staged credentials were retained.'); + sleep(250); } - const inspected = JSON.parse(before.stdout || '[]')[0] as { Config?: { Labels?: Record } } | undefined; + const inspected = JSON.parse(String(before.stdout || '[]'))[0] as { Config?: { Labels?: Record } } | undefined; if (inspected?.Config?.Labels?.['io.codeboost.invocation'] !== profile.ownershipId) throw new Error('Agent container name is held by another invocation; staged credentials were retained.'); const result = spawnSync('docker', ['rm', '--force', profile.name], { @@ -230,16 +242,25 @@ export function validateContainer(container: string, profile: ContainerProfile, export function createValidatedContainer(profile: ContainerProfile, timeoutMs = 30_000, secrets: Readonly> = {}): string { const remaining = createDeadline(timeoutMs); + let createUnsettled = false; try { validateSecrets(profile, secrets); assertContainerProfile(profile); - docker(profile.args, { timeoutMs: remaining(), secrets }); + const createTimeout = remaining(); + createUnsettled = true; + try { docker(profile.args, { timeoutMs: createTimeout, secrets }); } + catch (error) { + // A nonzero exit means the daemon answered; a killed client leaves the request in flight. + createUnsettled = typeof (error as { status?: unknown }).status !== 'number'; + throw error; + } + createUnsettled = false; validateContainer(profile.name, profile, remaining()); assertContainerProfile(profile); remaining(); return profile.name; } catch (error) { - try { removeContainerOrThrow(profile); } + try { removeContainerOrThrow(profile, createUnsettled); } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Container creation failed and cleanup did not settle.'); } throw error; } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index f97a3fb..80819ee 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -271,6 +271,20 @@ describe('real Docker agent isolation', () => { docker('rm', '--force', first.name); containers.delete(first.name); }, 60_000); + it('retains credentials when a killed create cannot be proven absent', () => { + const data = fixture(), unsettled = profile(data, 'planning', ['true']); + const shim = join(data.root, 'docker-shim'); mkdirSync(shim); + const realDocker = execFileSync('sh', ['-c', 'command -v docker'], { encoding: 'utf8' }).trim(); + // The create client hangs until its deadline kills it, so the daemon outcome stays unknown. + writeFileSync(join(shim, 'docker'), ['#!/bin/sh', 'if [ "$1" = create ]; then exec sleep 30; fi', + `exec '${realDocker}' "$@"`].join('\n'), { mode: 0o755 }); + const path = process.env.PATH; + process.env.PATH = `${shim}:${path}`; + try { expect(() => createValidatedContainer(unsettled, 1_000)).toThrow('cleanup did not settle'); } + finally { process.env.PATH = path; } + expect(existsSync(unsettled.codexAuthFile!)).toBe(true); + }, 60_000); + it('refuses a Codex auth path that is a link without resolving it', () => { const data = fixture(), link = join(data.root, 'auth-link.json'); symlinkSync(data.fakeAuth, link); @@ -340,7 +354,7 @@ describe('real Docker agent isolation', () => { expect(() => prepareTaskFilesystems({ ...data.clone }, { workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, }, imageId)).toThrow('trusted clone builder'); - }); + }, 60_000); if (process.env.CODEBOOST_RUN_AUTH_PROBES === '1') { it('runs the authenticated Codex startup path with isolated writable state', () => { From fb6577c944da38d1d6b070e4faab377a36fdc1aa Mon Sep 17 00:00:00 2001 From: mchwang Date: Thu, 24 Sep 2026 23:37:38 -0700 Subject: [PATCH 14/17] 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 --- agents/container/probe.sh | 1 + agents/container/profile.ts | 2 +- agents/container/run.ts | 10 ++++++---- agents/container/storage.ts | 9 +++++---- git/clone.ts | 25 +++++++++++++++++++++---- test/agent-container.test.ts | 28 +++++++++++++++++++++++++++- 6 files changed, 61 insertions(+), 14 deletions(-) diff --git a/agents/container/probe.sh b/agents/container/probe.sh index a10033e..70f9f24 100644 --- a/agents/container/probe.sh +++ b/agents/container/probe.sh @@ -22,6 +22,7 @@ for field in CapInh CapPrm CapEff CapBnd CapAmb; do || fail 'all capability sets must be empty' done [ "$(awk '/^NoNewPrivs:/ { print $2 }' /proc/self/status)" = '1' ] || fail 'no-new-privileges must be enabled' +[ "$(awk '/^Seccomp:/ { print $2 }' /proc/self/status)" = '2' ] || fail 'a seccomp syscall filter must be enforced' require_option / ro [ "${HOME:-}" = '/home/codeboost' ] || fail 'HOME must be the isolated home directory' diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 906e40f..b45d9bb 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -178,7 +178,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const name = `codeboost-agent-${safeName(invocation.attemptId)}`, ownershipId = randomUUID(); const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', - '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', + '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', '--cpus=1', '--shm-size=16m', '--ipc=private', '--cgroupns=private', '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, '--label', `io.codeboost.invocation=${ownershipId}`, diff --git a/agents/container/run.ts b/agents/container/run.ts index 01c5fe4..3476ada 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -31,8 +31,10 @@ const createDeadline = (timeoutMs: number) => { return value; }; }; -const exactNoNewPrivileges = (options: string[] | null | undefined) => options?.length === 1 - && (options[0] === 'no-new-privileges' || options[0] === 'no-new-privileges:true'); +// Only no-new-privileges plus Docker's builtin seccomp profile; the daemon default may be unconfined. +const exactSecurityOptions = (options: string[] | null | undefined) => options?.length === 2 + && options.some(option => option === 'no-new-privileges' || option === 'no-new-privileges:true') + && options.includes('seccomp=builtin'); export const hasExactOptions = (value: string | undefined, expected: readonly string[]) => { const parts = value?.split(',') ?? []; return parts.length === expected.length && new Set(parts).size === parts.length @@ -128,7 +130,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || inspect.Config.Labels?.['io.codeboost.invocation'] !== profile.ownershipId || !host.ReadonlyRootfs || host.Privileged || !host.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (host.CapAdd?.length ?? 0) !== 0 - || !exactNoNewPrivileges(host.SecurityOpt) + || !exactSecurityOptions(host.SecurityOpt) || host.NetworkMode !== 'none' || host.PidMode !== '' || host.IpcMode !== 'private' || host.UTSMode !== '' || host.UsernsMode !== '' || host.CgroupnsMode !== 'private' || (host.Devices?.length ?? 0) !== 0 || (host.DeviceRequests?.length ?? 0) !== 0 || host.PidsLimit !== 128 @@ -199,7 +201,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || keeper.HostConfig.Privileged || keeper.HostConfig.NetworkMode !== 'none' || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (keeper.HostConfig.CapAdd?.length ?? 0) !== 0 - || !exactNoNewPrivileges(keeper.HostConfig.SecurityOpt) + || !exactSecurityOptions(keeper.HostConfig.SecurityOpt) || keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume || keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume) throw new Error('Task filesystems must remain owned by their trusted keeper.'); diff --git a/agents/container/storage.ts b/agents/container/storage.ts index 084f455..fa32d0d 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -102,8 +102,7 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi for (const [name, value] of Object.entries(limits)) validLimit(value, name); if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) throw new Error('Task filesystems require the immutable built image ID.'); assertBuiltAgentImage(imageId); - assertTaskClone(clone); - const remaining = createDeadline(timeoutMs), staging = realpathSync(clone.directory); + const staging = assertTaskClone(clone), remaining = createDeadline(timeoutMs); if (/[\n,]/.test(staging)) throw new Error('Staging path cannot be represented as a Docker mount.'); if (!lstatSync(`${staging}/.git`).isDirectory()) throw new Error('Staging clone must contain standalone Git metadata.'); const allocationId = randomUUID(); @@ -125,16 +124,18 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/.git/. /metadata/', 'mkdir -p /work/.git', 'chown -R 10001:10001 /work /metadata'].join('; '); docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', - '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=32', '--memory=128m', '--cpus=.25', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=32', '--memory=128m', '--cpus=.25', '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, '--label', 'io.codeboost.task-storage=keeper', '--label', `io.codeboost.allocation=${allocationId}`, '--entrypoint', 'sleep', imageId, 'infinity'], remaining()); docker(['run', '--rm', '--name', seeder, '--label', `io.codeboost.allocation=${allocationId}`, '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN', - '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--pids-limit=32', + '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=32', '--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, '--entrypoint', 'sh', imageId, '-c', seed], remaining()); + // Reject a staging directory swapped while the seeder was reading it. + assertTaskClone(clone); remaining(); const filesystems = Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); allocations.set(filesystems, Object.freeze({ allocationId, diff --git a/git/clone.ts b/git/clone.ts index 879080f..edece0c 100644 --- a/git/clone.ts +++ b/git/clone.ts @@ -4,10 +4,25 @@ import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node: import { isAbsolute, join, relative, resolve } from 'node:path'; import type { TaskClone } from '../agents/contract.ts'; -const trustedClones = new WeakSet(); +interface DirectoryIdentity { readonly dev: number; readonly ino: number } +interface CloneIdentity { readonly directory: string; readonly root: DirectoryIdentity; readonly metadata: DirectoryIdentity } +const trustedClones = new WeakMap(); +const directoryIdentity = (path: string): DirectoryIdentity | undefined => { + const stat = lstatSync(path, { throwIfNoEntry: false }); + return stat?.isDirectory() && !stat.isSymbolicLink() ? { dev: stat.dev, ino: stat.ino } : undefined; +}; +const sameDirectory = (actual: DirectoryIdentity | undefined, expected: DirectoryIdentity) => + actual?.dev === expected.dev && actual.ino === expected.ino; -export function assertTaskClone(clone: TaskClone): void { - if (!trustedClones.has(clone)) throw new Error('Task clone was not created by the trusted clone builder.'); +/** Authenticate a clone and prove its staging directory is still the one the builder created. */ +export function assertTaskClone(clone: TaskClone): string { + const identity = trustedClones.get(clone); + if (!identity) throw new Error('Task clone was not created by the trusted clone builder.'); + if (!sameDirectory(directoryIdentity(identity.directory), identity.root) + || !sameDirectory(directoryIdentity(join(identity.directory, '.git')), identity.metadata) + || realpathSync(identity.directory) !== identity.directory) + throw new Error('Task clone directory was replaced after it was created.'); + return identity.directory; } /** @@ -86,7 +101,9 @@ export function createTaskClone(options: { run(directory, 'checkout', '--detach', options.head); if (run(directory, 'rev-parse', 'HEAD') !== options.head) throw new Error('Task head changed during clone.'); const clone = Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head }); - trustedClones.add(clone); + const root = directoryIdentity(directory), cloneMetadata = directoryIdentity(metadata); + if (!root || !cloneMetadata) throw new Error('Task clone directory is not a real directory.'); + trustedClones.set(clone, Object.freeze({ directory: realpathSync(directory), root, metadata: cloneMetadata })); return clone; } catch (error) { rmSync(directory, { recursive: true, force: true }); diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 80819ee..a0880eb 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomBytes, randomUUID } from 'node:crypto'; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -285,6 +285,32 @@ describe('real Docker agent isolation', () => { expect(existsSync(unsettled.codexAuthFile!)).toBe(true); }, 60_000); + it('refuses to seed a clone whose staging directory was replaced after creation', () => { + const data = fixture(); + const clone = createTaskClone({ source: data.source, parent: join(data.root, 'staging'), taskId: 'task-2', + head: git(data.source, 'rev-parse', 'HEAD') }); + renameSync(clone.directory, `${clone.directory}-original`); + mkdirSync(clone.directory); git(clone.directory, 'init'); + expect(() => prepareTaskFilesystems(clone, { + workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, + }, imageId)).toThrow('replaced after it was created'); + }, 60_000); + + it('rejects a container that relies on the daemon default seccomp profile', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + docker(...valid.args.filter(arg => arg !== '--security-opt=seccomp=builtin')); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + }, 60_000); + + it('startup probe refuses to exec when seccomp filtering is disabled', () => { + const result = spawnSync('docker', ['run', '--rm', '--read-only', '--user', '10001:10001', '--cap-drop=ALL', + '--security-opt=no-new-privileges', '--security-opt=seccomp=unconfined', '--network=none', imageId, 'true'], + { encoding: 'utf8', timeout: 60_000 }); + expect(result.status).toBe(78); + expect(result.stderr).toContain('seccomp syscall filter must be enforced'); + }, 60_000); + it('refuses a Codex auth path that is a link without resolving it', () => { const data = fixture(), link = join(data.root, 'auth-link.json'); symlinkSync(data.fakeAuth, link); From 162554e6b91948fd1cb4f5a70447375174d2dbf0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 00:04:45 -0700 Subject: [PATCH 15/17] 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 --- agents/container/profile.ts | 14 ++++++-- agents/container/run.ts | 11 +++--- agents/container/storage.ts | 66 ++++++++++++++++++++++++------------ test/agent-container.test.ts | 52 ++++++++++++++++++++++++++-- 4 files changed, 113 insertions(+), 30 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index b45d9bb..5748224 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -40,7 +40,7 @@ interface FileIdentity { } interface ProfileIdentity { readonly inputDirectory: string; readonly schema: FileIdentity; readonly auth?: FileIdentity; readonly cleanupDirectories: readonly string[]; readonly filesystems: TaskFilesystems; - readonly clone: InvocationInput['clone'] } + readonly clone: InvocationInput['clone']; readonly deadline: number } type InputIdentity = Pick; interface InputCapture extends InputIdentity { readonly content: Buffer } const identities = new WeakMap(); @@ -118,6 +118,15 @@ export function assertContainerProfile(profile: ContainerProfile): void { } } +/** Clamp a Docker budget to the captured invocation deadline, which no launch may outlive. */ +export function profileTimeout(profile: ContainerProfile, timeoutMs: number, now = Date.now()): number { + const expected = identities.get(profile); + if (!expected) throw new Error('Container profile was not created by the trusted profile builder.'); + const left = Math.floor(expected.deadline - now); + if (left < 1) throw new Error('Invocation deadline has passed.'); + return Math.min(timeoutMs, left); +} + /** Remove runner-owned credential staging after this one-shot profile settles. */ export function disposeContainerProfile(profile: ContainerProfile): void { const identity = identities.get(profile); @@ -204,7 +213,8 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil command: Object.freeze([...options.command]), ownershipId }); identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema, auth: authIdentity, - cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone })); + cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone, + deadline: invocation.deadline })); return profile; } catch (error) { try { removeOwnedDirectories(cleanupDirectories); } diff --git a/agents/container/run.ts b/agents/container/run.ts index 3476ada..8f201f6 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { realpathSync } from 'node:fs'; -import { assertContainerProfile, disposeContainerProfile, type ContainerProfile } from './profile.ts'; +import { assertContainerProfile, disposeContainerProfile, profileTimeout, type ContainerProfile } from './profile.ts'; import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts'; import { taskFilesystemAllocationId } from './storage.ts'; export { prepareTaskFilesystems, removeTaskFilesystems } from './storage.ts'; @@ -192,7 +192,8 @@ export function validateContainer(container: string, profile: ContainerProfile, const keeper = JSON.parse(docker(['container', 'inspect', profile.filesystems.keeper], { timeoutMs: remaining() }))[0] as { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record }; HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; NetworkMode?: string; CapDrop?: string[] | null; - CapAdd?: string[] | null; SecurityOpt?: string[] | null }; + CapAdd?: string[] | null; SecurityOpt?: string[] | null; + RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null }; Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); if (!keeper?.State?.Running || keeper.Config?.Image !== profile.expectedImage || keeper.Config?.User !== '10001:10001' @@ -202,6 +203,8 @@ export function validateContainer(container: string, profile: ContainerProfile, || !keeper.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') || (keeper.HostConfig.CapAdd?.length ?? 0) !== 0 || !exactSecurityOptions(keeper.HostConfig.SecurityOpt) + || !['', 'no'].includes(keeper.HostConfig.RestartPolicy?.Name ?? '') + || (keeper.HostConfig.RestartPolicy?.MaximumRetryCount ?? 0) !== 0 || keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume || keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume) throw new Error('Task filesystems must remain owned by their trusted keeper.'); @@ -243,7 +246,7 @@ export function validateContainer(container: string, profile: ContainerProfile, export function createValidatedContainer(profile: ContainerProfile, timeoutMs = 30_000, secrets: Readonly> = {}): string { - const remaining = createDeadline(timeoutMs); + const remaining = createDeadline(profileTimeout(profile, timeoutMs)); let createUnsettled = false; try { validateSecrets(profile, secrets); @@ -270,7 +273,7 @@ export function createValidatedContainer(profile: ContainerProfile, timeoutMs = export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, secrets: Readonly> = {}): string { - const remaining = createDeadline(timeoutMs); + const remaining = createDeadline(profileTimeout(profile, timeoutMs)); const container = createValidatedContainer(profile, remaining(), secrets); let failure: unknown; try { diff --git a/agents/container/storage.ts b/agents/container/storage.ts index fa32d0d..80d1153 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { lstatSync, realpathSync } from 'node:fs'; +import { lstatSync } from 'node:fs'; import type { TaskClone } from '../contract.ts'; import { assertTaskClone } from '../../git/clone.ts'; import { assertBuiltAgentImage } from './image.ts'; @@ -24,6 +24,8 @@ export interface TaskStorageLimits { interface AllocationIdentity { readonly allocationId: string; readonly clone: Readonly; + /** The builder-registered clone object, whose staging directory identity is re-verified. */ + readonly trustedClone: TaskClone; readonly limits: Readonly; } const allocations = new WeakMap(); @@ -46,15 +48,22 @@ const docker = (args: readonly string[], timeoutMs: number) => execFileSync('doc }).trim(); const absent = (result: ReturnType) => result.status !== 0 && !result.error && /No such (?:object|container|volume)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); +/** How long an object whose create client was killed may still materialize in the daemon. */ +const CREATE_SETTLE_MS = 10_000; +const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); const remove = (args: readonly string[], inspectArgs: readonly string[], remaining: () => number, kind: string, - allocationId: string) => { - const before = spawnSync('docker', [...inspectArgs], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', - env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); - if (before.status !== 0) { - if (absent(before)) return; - throw new Error(`Failed to establish ownership of ${kind}.`); + allocationId: string, settleBy = 0) => { + let before: ReturnType; + for (;;) { + before = spawnSync('docker', [...inspectArgs], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', + env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (before.status === 0) break; + if (!absent(before)) throw new Error(`Failed to establish ownership of ${kind}.`); + // A killed create may still land; only absence after the settle window counts. + if (performance.now() >= settleBy) return; + sleep(250); } - const inspected = JSON.parse(before.stdout || '[]')[0] as + const inspected = JSON.parse(String(before.stdout || '[]'))[0] as { Labels?: Record; Config?: { Labels?: Record } } | undefined; const labels = inspected?.Labels ?? inspected?.Config?.Labels; if (labels?.['io.codeboost.allocation'] !== allocationId) throw new Error(`Refused to remove unowned ${kind}.`); @@ -65,15 +74,18 @@ const remove = (args: readonly string[], inspectArgs: readonly string[], remaini env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }); if (!absent(inspect)) throw new Error(`Failed to confirm removal of ${kind}.`); }; -const cleanup = (containers: readonly string[], volumes: readonly string[], allocationId: string, timeoutMs = 30_000) => { - const remaining = createDeadline(timeoutMs), failures: unknown[] = []; +const cleanup = (containers: readonly string[], volumes: readonly string[], allocationId: string, + unsettled: ReadonlySet = new Set(), timeoutMs = 30_000) => { + const remaining = createDeadline(timeoutMs + (unsettled.size ? CREATE_SETTLE_MS : 0)), failures: unknown[] = []; + const settleBy = (name: string) => unsettled.has(name) ? performance.now() + CREATE_SETTLE_MS : 0; for (const container of containers) { try { remove(['rm', '--force', container], ['container', 'inspect', container], remaining, - 'task container', allocationId); } + 'task container', allocationId, settleBy(container)); } catch (error) { failures.push(error); } } for (const volume of volumes) { - try { remove(['volume', 'rm', '--force', volume], ['volume', 'inspect', volume], remaining, 'task volume', allocationId); } + try { remove(['volume', 'rm', '--force', volume], ['volume', 'inspect', volume], remaining, 'task volume', allocationId, + settleBy(volume)); } catch (error) { failures.push(error); } } if (failures.length) throw new AggregateError(failures, 'Task filesystem cleanup did not settle.'); @@ -87,7 +99,8 @@ export function assertTaskFilesystems(filesystems: TaskFilesystems, clone?: Task || filesystems.metadataBytes !== limits.metadataBytes || filesystems.metadataInodes !== limits.metadataInodes) throw new Error('Task filesystem limits changed after allocation.'); if (clone && (clone.id !== identity.clone.id || clone.taskId !== identity.clone.taskId - || realpathSync(clone.directory) !== identity.clone.directory || clone.head !== identity.clone.head)) + || clone.directory !== identity.trustedClone.directory + || assertTaskClone(identity.trustedClone) !== identity.clone.directory || clone.head !== identity.clone.head)) throw new Error('Task filesystems do not belong to the invocation clone.'); } @@ -108,14 +121,23 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi const allocationId = randomUUID(); const workVolume = `codeboost-work-${randomUUID()}`, metadataVolume = `codeboost-metadata-${randomUUID()}`; const keeper = `codeboost-keeper-${randomUUID()}`, seeder = `codeboost-seeder-${randomUUID()}`; - const createdVolumes: string[] = []; + const createdVolumes: string[] = [], unsettled = new Set(); + // Run one allocation step; a client killed by its deadline leaves the daemon outcome for `name` unknown. + const allocate = (name: string, args: readonly string[]) => { + const timeout = remaining(); + try { docker(args, timeout); } + catch (error) { + if (typeof (error as { status?: unknown }).status !== 'number') unsettled.add(name); + throw error; + } + }; try { for (const [kind, name, bytes, inodes] of [['work', workVolume, limits.workBytes, limits.workInodes], ['metadata', metadataVolume, limits.metadataBytes, limits.metadataInodes]] as const) { createdVolumes.push(name); - docker(['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', + allocate(name, ['volume', 'create', '--driver', 'local', '--opt', 'type=tmpfs', '--opt', 'device=tmpfs', '--opt', `o=size=${bytes},nr_inodes=${inodes},uid=10001,gid=10001,mode=0755,nosuid,nodev`, - '--label', `io.codeboost.task-storage=${kind}`, '--label', `io.codeboost.allocation=${allocationId}`, name], remaining()); + '--label', `io.codeboost.task-storage=${kind}`, '--label', `io.codeboost.allocation=${allocationId}`, name]); } // Copy metadata straight to its own volume so the work allocation never holds both at once. const seed = ['set -eu', @@ -123,26 +145,26 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi + ' -exec cp -a --no-preserve=ownership,timestamps -t /work/ {} +', 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/.git/. /metadata/', 'mkdir -p /work/.git', 'chown -R 10001:10001 /work /metadata'].join('; '); - docker(['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', + allocate(keeper, ['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=32', '--memory=128m', '--cpus=.25', '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, '--label', 'io.codeboost.task-storage=keeper', '--label', `io.codeboost.allocation=${allocationId}`, - '--entrypoint', 'sleep', imageId, 'infinity'], remaining()); - docker(['run', '--rm', '--name', seeder, '--label', `io.codeboost.allocation=${allocationId}`, + '--entrypoint', 'sleep', imageId, 'infinity']); + allocate(seeder, ['run', '--rm', '--name', seeder, '--label', `io.codeboost.allocation=${allocationId}`, '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN', '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=32', '--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, - '--entrypoint', 'sh', imageId, '-c', seed], remaining()); + '--entrypoint', 'sh', imageId, '-c', seed]); // Reject a staging directory swapped while the seeder was reading it. assertTaskClone(clone); remaining(); const filesystems = Object.freeze({ keeper, workVolume, metadataVolume, ...limits }); - allocations.set(filesystems, Object.freeze({ allocationId, + allocations.set(filesystems, Object.freeze({ allocationId, trustedClone: clone, clone: Object.freeze({ ...clone, directory: staging }), limits: Object.freeze({ ...limits }) })); return filesystems; } catch (error) { - try { cleanup([seeder, keeper], createdVolumes.reverse(), allocationId); } + try { cleanup([seeder, keeper], createdVolumes.reverse(), allocationId, unsettled); } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Task allocation failed and cleanup did not settle.'); } throw error; } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index a0880eb..48fda95 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -45,9 +45,10 @@ function fixture(options: { limits?: Parameters[1 return { root, source, input, clone, filesystems, fakeAuth }; } -function invocation(clone: ReturnType, phase: Phase, vendor: 'codex' | 'claude' = 'codex'): InvocationInput { +function invocation(clone: ReturnType, phase: Phase, vendor: 'codex' | 'claude' = 'codex', + deadlineMs = 60_000): InvocationInput { return captureInvocation({ clone, phase, vendor, approvedArgv: phase === 'planning' || phase === 'questions' ? [] : [['git', 'status']], - deadline: Date.now() + 60_000, attemptId: `${vendor}-${phase}-${Math.random().toString(16).slice(2)}`, + deadline: Date.now() + deadlineMs, attemptId: `${vendor}-${phase}-${Math.random().toString(16).slice(2)}`, context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', referencedCodeHash: 'code-1', stateVersion: 1 } }); } @@ -311,6 +312,53 @@ describe('real Docker agent isolation', () => { expect(result.stderr).toContain('seccomp syscall filter must be enforced'); }, 60_000); + it('removes a keeper that lands in the daemon after its run client was killed', () => { + const data = fixture(); + const clone = createTaskClone({ source: data.source, parent: join(data.root, 'staging'), taskId: 'task-late', + head: git(data.source, 'rev-parse', 'HEAD') }); + const keepers = () => new Set(docker('ps', '--all', '--quiet', '--filter', 'label=io.codeboost.task-storage=keeper') + .split('\n').filter(Boolean)); + const before = keepers(); + const shim = join(data.root, 'docker-shim'); mkdirSync(shim); + const realDocker = execFileSync('sh', ['-c', 'command -v docker'], { encoding: 'utf8' }).trim(); + // The keeper's run client hangs until killed, and the real run lands in the daemon afterwards. + writeFileSync(join(shim, 'docker'), ['#!/bin/sh', + `if [ "$1" = run ] && [ "$2" = --detach ]; then ( sleep 4; exec '${realDocker}' "$@" ) >/dev/null 2>&1 prepareTaskFilesystems(clone, { + workBytes: 16 * 1024 * 1024, workInodes: 512, metadataBytes: 16 * 1024 * 1024, metadataInodes: 512, + }, imageId, 2_000)).toThrow(); + } finally { process.env.PATH = path; } + execFileSync('sleep', ['6']); + const orphans = [...keepers()].filter(id => !before.has(id)); + for (const id of orphans) docker('rm', '--force', id); + expect(orphans).toEqual([]); + }, 60_000); + + it('rejects a task keeper whose restart policy was changed', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + docker('update', '--restart=always', data.filesystems.keeper); + try { + docker(...valid.args); containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('trusted keeper'); + } finally { docker('update', '--restart=no', data.filesystems.keeper); } + docker('rm', '--force', valid.name); containers.delete(valid.name); + }, 60_000); + + it('stops a running agent at the captured invocation deadline', () => { + const data = fixture(); + const late = createContainerProfile({ invocation: invocation(data.clone, 'planning', 'codex', 4_000), + filesystems: data.filesystems, inputDirectory: data.input, command: ['sh', '-c', 'sleep 30'], + codexAuthFile: data.fakeAuth, imageId }); + profiles.push(late); + const started = performance.now(); + expect(() => runContainer(late, 60_000)).toThrow(); + expect(performance.now() - started).toBeLessThan(15_000); + }, 60_000); + it('refuses a Codex auth path that is a link without resolving it', () => { const data = fixture(), link = join(data.root, 'auth-link.json'); symlinkSync(data.fakeAuth, link); From 72fb0641fc397e624e8e17354ae7f0b825e932cf Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 00:12:01 -0700 Subject: [PATCH 16/17] 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 --- .github/workflows/agent-isolation.yml | 3 ++- agents/container/profile.ts | 4 +++- agents/contract.ts | 11 ++++++++++- test/agent-container.test.ts | 7 +++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml index 0ac896d..f709abf 100644 --- a/.github/workflows/agent-isolation.yml +++ b/.github/workflows/agent-isolation.yml @@ -18,7 +18,8 @@ permissions: jobs: real-docker: runs-on: ubuntu-latest - timeout-minutes: 15 + # Covers the 10 min image build, 2 min teardown and the per-test Docker budgets. + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 5748224..87a081c 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -3,7 +3,7 @@ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdtempSync, ope readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { InvocationInput, Phase } from '../contract.ts'; +import { assertCapturedInvocation, type InvocationInput, type Phase } from '../contract.ts'; import { assertBuiltAgentImage } from './image.ts'; import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts'; export interface ContainerProfile { @@ -148,6 +148,8 @@ const mountSource = (path: string, kind: string) => { export function createContainerProfile(options: ProfileOptions): ContainerProfile { const { invocation, filesystems } = options; + // Phase, vendor and deadline drive mount modes and credentials, so they must come from a captured request. + assertCapturedInvocation(invocation); if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0'))) throw new Error('Container command must be a complete literal argv array.'); if (!/^sha256:[0-9a-f]{64}$/.test(options.imageId)) diff --git a/agents/contract.ts b/agents/contract.ts index dbf6731..c65ae11 100644 --- a/agents/contract.ts +++ b/agents/contract.ts @@ -53,6 +53,13 @@ export interface InvocationHandle { const nonempty = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && !value.includes('\0'); const integer = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; +const capturedInvocations = new WeakSet(); + +/** Authenticate a request produced by captureInvocation, so a copied or edited request cannot pass. */ +export function assertCapturedInvocation(input: InvocationInput): void { + if (!capturedInvocations.has(input)) throw new Error('Invocation was not captured by the trusted capture boundary.'); +} + /** Capture a deep immutable request so caller edits cannot change an active run. */ export function captureInvocation(input: InvocationInput, now = Date.now()): InvocationInput { if (!input || !input.clone || !input.context) throw new Error('Missing invocation context.'); @@ -71,8 +78,10 @@ export function captureInvocation(input: InvocationInput, now = Date.now()): Inv throw new Error('Commands must be complete literal argv arrays.'); if (['planning', 'questions'].includes(input.phase) && input.approvedArgv.length) throw new Error('Read-only authoring and questions cannot execute commands.'); - return Object.freeze({ ...input, clone: Object.freeze({ ...input.clone }), context: Object.freeze({ ...context }), + const captured = Object.freeze({ ...input, clone: Object.freeze({ ...input.clone }), context: Object.freeze({ ...context }), approvedArgv: Object.freeze(input.approvedArgv.map(argv => Object.freeze([...argv]))) }); + capturedInvocations.add(captured); + return captured; } /** Dispatcher predicate, not a sandbox. An adapter must enforce this externally. */ diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index 48fda95..d097c98 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -359,6 +359,13 @@ describe('real Docker agent isolation', () => { expect(performance.now() - started).toBeLessThan(15_000); }, 60_000); + it('refuses an invocation copied from a captured request with a different phase', () => { + const data = fixture(), captured = invocation(data.clone, 'review'); + const forged = { ...captured, phase: 'execute' as Phase }; + expect(() => createContainerProfile({ invocation: forged, filesystems: data.filesystems, + inputDirectory: data.input, command: ['true'], codexAuthFile: data.fakeAuth, imageId })).toThrow('captured'); + }, 60_000); + it('refuses a Codex auth path that is a link without resolving it', () => { const data = fixture(), link = join(data.root, 'auth-link.json'); symlinkSync(data.fakeAuth, link); From b66f236fbbd32a567320b299aec8c0178fe1d71f Mon Sep 17 00:00:00 2001 From: mchwang Date: Fri, 25 Sep 2026 00:22:22 -0700 Subject: [PATCH 17/17] 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 --- agents/container/profile.ts | 2 +- agents/container/run.ts | 9 +++++---- agents/container/storage.ts | 4 ++-- agents/contract.ts | 5 +++++ test/agent-container.test.ts | 8 ++++++++ test/agent-contract.test.ts | 9 ++++++++- 6 files changed, 29 insertions(+), 8 deletions(-) diff --git a/agents/container/profile.ts b/agents/container/profile.ts index 87a081c..41a85ed 100644 --- a/agents/container/profile.ts +++ b/agents/container/profile.ts @@ -189,7 +189,7 @@ export function createContainerProfile(options: ProfileOptions): ContainerProfil const name = `codeboost-agent-${safeName(invocation.attemptId)}`, ownershipId = randomUUID(); const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', - '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', + '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--pids-limit=128', '--memory=512m', '--memory-swap=512m', '--cpus=1', '--shm-size=16m', '--ipc=private', '--cgroupns=private', '--network=none', '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, '--label', `io.codeboost.invocation=${ownershipId}`, diff --git a/agents/container/run.ts b/agents/container/run.ts index 8f201f6..b26847a 100644 --- a/agents/container/run.ts +++ b/agents/container/run.ts @@ -99,7 +99,7 @@ type Inspect = { Ulimits: unknown[] | null; CpuCount: number; CpuPercent: number; IOMaximumBandwidth: number; IOMaximumIOps: number; DeviceCgroupRules: unknown[] | null; StorageOpt?: Record | null; CgroupParent: string; - RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null; + RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null; Runtime: string; Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record | null; Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean }> | null }; Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>; @@ -143,7 +143,8 @@ export function validateContainer(container: string, profile: ContainerProfile, || host.BlkioDeviceReadIOps?.length || host.BlkioDeviceWriteIOps?.length || host.Ulimits?.length || host.CpuCount !== 0 || host.CpuPercent !== 0 || host.IOMaximumBandwidth !== 0 || host.IOMaximumIOps !== 0 || host.DeviceCgroupRules !== null || host.StorageOpt != null || host.CgroupParent !== '' - || !['', 'no'].includes(host.RestartPolicy?.Name ?? '') || (host.RestartPolicy?.MaximumRetryCount ?? 0) !== 0) + || !['', 'no'].includes(host.RestartPolicy?.Name ?? '') || (host.RestartPolicy?.MaximumRetryCount ?? 0) !== 0 + || host.Runtime !== 'runc') throw new Error('Container daemon configuration is missing required lockdown.'); const tmpfs = host.Tmpfs ?? {}; const expectedTmpfs = new Map([ @@ -193,7 +194,7 @@ export function validateContainer(container: string, profile: ContainerProfile, { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record }; HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; NetworkMode?: string; CapDrop?: string[] | null; CapAdd?: string[] | null; SecurityOpt?: string[] | null; - RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null }; + RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null; Runtime?: string }; Mounts?: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }> } | undefined; const keeperVolumes = new Map((keeper?.Mounts ?? []).filter(item => item.Type === 'volume').map(item => [item.Destination, item])); if (!keeper?.State?.Running || keeper.Config?.Image !== profile.expectedImage || keeper.Config?.User !== '10001:10001' @@ -204,7 +205,7 @@ export function validateContainer(container: string, profile: ContainerProfile, || (keeper.HostConfig.CapAdd?.length ?? 0) !== 0 || !exactSecurityOptions(keeper.HostConfig.SecurityOpt) || !['', 'no'].includes(keeper.HostConfig.RestartPolicy?.Name ?? '') - || (keeper.HostConfig.RestartPolicy?.MaximumRetryCount ?? 0) !== 0 + || (keeper.HostConfig.RestartPolicy?.MaximumRetryCount ?? 0) !== 0 || keeper.HostConfig.Runtime !== 'runc' || keeperVolumes.get('/work')?.Name !== profile.filesystems.workVolume || keeperVolumes.get('/metadata')?.Name !== profile.filesystems.metadataVolume) throw new Error('Task filesystems must remain owned by their trusted keeper.'); diff --git a/agents/container/storage.ts b/agents/container/storage.ts index 80d1153..f8b19a2 100644 --- a/agents/container/storage.ts +++ b/agents/container/storage.ts @@ -146,13 +146,13 @@ export function prepareTaskFilesystems(clone: TaskClone, limits: TaskStorageLimi 'cp -a --no-preserve=ownership,timestamps /run/codeboost-staging/.git/. /metadata/', 'mkdir -p /work/.git', 'chown -R 10001:10001 /work /metadata'].join('; '); allocate(keeper, ['run', '--detach', '--name', keeper, '--read-only', '--user', '10001:10001', '--network=none', - '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=32', '--memory=128m', '--cpus=.25', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--pids-limit=32', '--memory=128m', '--cpus=.25', '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, '--label', 'io.codeboost.task-storage=keeper', '--label', `io.codeboost.allocation=${allocationId}`, '--entrypoint', 'sleep', imageId, 'infinity']); allocate(seeder, ['run', '--rm', '--name', seeder, '--label', `io.codeboost.allocation=${allocationId}`, '--read-only', '--user', '0:0', '--network=none', '--cap-drop=ALL', '--cap-add=CHOWN', - '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--pids-limit=32', + '--cap-add=DAC_OVERRIDE', '--cap-add=FOWNER', '--security-opt=no-new-privileges', '--security-opt=seccomp=builtin', '--runtime=runc', '--pids-limit=32', '--memory=128m', '--cpus=.25', '--mount', `type=bind,source=${staging},target=/run/codeboost-staging,readonly`, '--mount', `type=volume,source=${workVolume},target=/work`, '--mount', `type=volume,source=${metadataVolume},target=/metadata`, '--entrypoint', 'sh', imageId, '-c', seed]); diff --git a/agents/contract.ts b/agents/contract.ts index c65ae11..b070931 100644 --- a/agents/contract.ts +++ b/agents/contract.ts @@ -54,6 +54,8 @@ const nonempty = (value: unknown): value is string => typeof value === 'string' const integer = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; const capturedInvocations = new WeakSet(); +// One capture per attempt: an existing request cannot be re-captured with an upgraded phase, deadline or allowlist. +const capturedAttempts = new Set(); /** Authenticate a request produced by captureInvocation, so a copied or edited request cannot pass. */ export function assertCapturedInvocation(input: InvocationInput): void { @@ -78,9 +80,12 @@ export function captureInvocation(input: InvocationInput, now = Date.now()): Inv throw new Error('Commands must be complete literal argv arrays.'); if (['planning', 'questions'].includes(input.phase) && input.approvedArgv.length) throw new Error('Read-only authoring and questions cannot execute commands.'); + if (capturedAttempts.has(input.attemptId)) + throw new Error('Attempt was already captured; a new invocation requires a new attempt identity.'); const captured = Object.freeze({ ...input, clone: Object.freeze({ ...input.clone }), context: Object.freeze({ ...context }), approvedArgv: Object.freeze(input.approvedArgv.map(argv => Object.freeze([...argv]))) }); capturedInvocations.add(captured); + capturedAttempts.add(captured.attemptId); return captured; } diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts index d097c98..2478dcc 100644 --- a/test/agent-container.test.ts +++ b/test/agent-container.test.ts @@ -372,6 +372,14 @@ describe('real Docker agent isolation', () => { expect(() => profile(data, 'planning', ['true'], { codexAuthFile: link })).toThrow('not a link'); }, 60_000); + it('rejects an alternate Docker runtime that may not honour the checked isolation', () => { + const data = fixture(), valid = profile(data, 'planning', ['true']); + docker(...valid.args.map(arg => arg === '--runtime=runc' ? '--runtime=io.containerd.runc.v2' : arg)); + containers.add(valid.name); + expect(() => validateContainer(valid.name, valid)).toThrow('lockdown'); + docker('rm', '--force', valid.name); containers.delete(valid.name); + }, 60_000); + it('rejects a restart policy that could relaunch the agent after it exits', () => { const data = fixture(), valid = profile(data, 'planning', ['true']); const imageIndex = valid.args.indexOf(imageId); diff --git a/test/agent-contract.test.ts b/test/agent-contract.test.ts index c498231..323b6dc 100644 --- a/test/agent-contract.test.ts +++ b/test/agent-contract.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; import { captureInvocation, permitsCommand, type InvocationInput } from '../agents/contract.ts'; +let attempt = 0; const request = (): InvocationInput => ({ clone: { id: 'clone-1', taskId: 'task-1', directory: '/tasks/one', head: 'a'.repeat(40) }, - vendor: 'codex', phase: 'review', approvedArgv: [['npm', 'test']], deadline: 2000, attemptId: 'attempt-1', + vendor: 'codex', phase: 'review', approvedArgv: [['npm', 'test']], deadline: 2000, attemptId: `attempt-${++attempt}`, context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', referencedCodeHash: 'hash-1', stateVersion: 3 }, }); @@ -39,6 +40,12 @@ describe('invocation boundary', () => { expect(() => captureInvocation({ ...request(), phase: 'shell' } as unknown as InvocationInput, 1000)).toThrow('profile'); expect(() => captureInvocation({ ...request(), attemptId: '' }, 1000)).toThrow('identity'); }); + it('refuses to re-capture an attempt with an upgraded phase, deadline or allowlist', () => { + const review = captureInvocation(request(), 1000); + expect(() => captureInvocation({ ...review, phase: 'execute', deadline: 5000, + approvedArgv: [['sh', '-c', 'anything']] }, 1000)).toThrow('already captured'); + expect(() => captureInvocation({ ...review }, 1000)).toThrow('already captured'); + }); it('rejects sparse allowlists with missing arguments or commands', () => { const argv = ['npm', 'test']; delete argv[1]; expect(1 in argv).toBe(false);