diff --git a/.github/workflows/agent-isolation.yml b/.github/workflows/agent-isolation.yml new file mode 100644 index 0000000..f709abf --- /dev/null +++ b/.github/workflows/agent-isolation.yml @@ -0,0 +1,31 @@ +name: Agent isolation +on: + push: + branches: [main] + paths: + - 'agents/**' + - 'git/clone.ts' + - 'test/agent-*.test.ts' + - '.github/workflows/agent-isolation.yml' + 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 + # 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 + 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..6711760 --- /dev/null +++ b/agents/container/Dockerfile @@ -0,0 +1,23 @@ +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 \ + && install --directory --owner=10001 --group=10001 --mode=0755 /work /work/.git + +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..cdfc655 --- /dev/null +++ b/agents/container/image.ts @@ -0,0 +1,40 @@ +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)); +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.'); + 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 } }; + remaining(); + 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.'); + trustedImages.add(inspect.Id); + return inspect.Id; +} diff --git a/agents/container/probe.sh b/agents/container/probe.sh new file mode 100644 index 0000000..70f9f24 --- /dev/null +++ b/agents/container/probe.sh @@ -0,0 +1,84 @@ +#!/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"; } +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() { + # 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" +} + +[ "$(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' +[ "$(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' +[ "${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..41a85ed --- /dev/null +++ b/agents/container/profile.ts @@ -0,0 +1,226 @@ +import { createHash, randomUUID } from 'node:crypto'; +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'; +import { assertCapturedInvocation, type InvocationInput, type Phase } from '../contract.ts'; +import { assertBuiltAgentImage } from './image.ts'; +import { assertTaskFilesystems, type TaskFilesystems } from './storage.ts'; +export interface ContainerProfile { + readonly name: string; + readonly args: readonly string[]; + readonly expectedImage: string; + readonly phase: Phase; + readonly vendor: 'claude' | 'codex'; + readonly filesystems: TaskFilesystems; + readonly inputDirectory: string; + readonly codexAuthFile?: string; + readonly command: readonly string[]; + readonly ownershipId: string; +} +export interface ProfileOptions { + readonly invocation: InvocationInput; + readonly filesystems: TaskFilesystems; + readonly inputDirectory: string; + readonly command: readonly string[]; + readonly imageId: string; + readonly codexAuthFile?: string; + 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; + readonly cleanupDirectories: readonly string[]; readonly filesystems: TaskFilesystems; + readonly clone: InvocationInput['clone']; readonly deadline: number } +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 { + 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) + throw new Error(`${kind} must be a bounded, unlinked regular file.`); + 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) + throw new Error(`${kind} changed while its identity was captured.`); + 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 + && actual.digest === expected.digest; +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.'); + 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 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, content: captured.content }); +}; + +/** 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.'); + 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.'); + 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.'); + } +} + +/** 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); + if (!identity) return; + removeOwnedDirectories(identity.cleanupDirectories); + identities.delete(profile); +} + +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; + // 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)) + throw new Error('Container profile requires the immutable built image ID.'); + assertBuiltAgentImage(options.imageId); + assertTaskFilesystems(filesystems, invocation.clone); + 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)) + 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.'); + // 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 { + 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'); + } + 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', '--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}`, + '--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, + deadline: invocation.deadline })); + return profile; + } catch (error) { + try { removeOwnedDirectories(cleanupDirectories); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Profile creation and cleanup both failed.'); } + throw error; + } +} diff --git a/agents/container/run.ts b/agents/container/run.ts new file mode 100644 index 0000000..b26847a --- /dev/null +++ b/agents/container/run.ts @@ -0,0 +1,294 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +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'; +export type { TaskFilesystems, TaskStorageLimits } from './storage.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; + }; +}; +// 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 + && 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; } +}; +/** 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.'); + 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(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], { + 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: remaining(), env: dockerEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], + }); + 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); +}; + +type Inspect = { + Image: 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; UTSMode: string; UsernsMode: string; CgroupnsMode: string; + 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[] | 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; + 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 }>; +}; + +/** 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; 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 + || 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) + || 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 + || !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 + || 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 !== '' + || !['', '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([ + ['/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) { + 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', + ...(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 + || 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.'); + 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)]], + ]); + 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] + || 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.'); + } + 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; + 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' + || 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 + || !exactSecurityOptions(keeper.HostConfig.SecurityOpt) + || !['', 'no'].includes(keeper.HostConfig.RestartPolicy?.Name ?? '') + || (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.'); + 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 + || 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.'); + 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('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) + || environment.get('CODEBOOST_METADATA_BYTES') !== String(profile.filesystems.metadataBytes) + || 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') + || 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); + remaining(); +} + +export function createValidatedContainer(profile: ContainerProfile, timeoutMs = 30_000, + secrets: Readonly> = {}): string { + const remaining = createDeadline(profileTimeout(profile, timeoutMs)); + let createUnsettled = false; + try { + validateSecrets(profile, secrets); + assertContainerProfile(profile); + 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, createUnsettled); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Container creation failed and cleanup did not settle.'); } + throw error; + } +} + +export function runContainer(profile: ContainerProfile, timeoutMs = 60_000, + secrets: Readonly> = {}): string { + const remaining = createDeadline(profileTimeout(profile, 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 { + 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/agents/container/storage.ts b/agents/container/storage.ts new file mode 100644 index 0000000..f8b19a2 --- /dev/null +++ b/agents/container/storage.ts @@ -0,0 +1,178 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstatSync } from 'node:fs'; +import type { TaskClone } from '../contract.ts'; +import { assertTaskClone } from '../../git/clone.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; + /** The builder-registered clone object, whose staging directory identity is re-verified. */ + readonly trustedClone: TaskClone; + 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 ?? ''}`); +/** 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, 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(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}.`); + 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 = (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, 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, + settleBy(volume)); } + 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 + || 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.'); +} + +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 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(); + const workVolume = `codeboost-work-${randomUUID()}`, metadataVolume = `codeboost-metadata-${randomUUID()}`; + const keeper = `codeboost-keeper-${randomUUID()}`, seeder = `codeboost-seeder-${randomUUID()}`; + 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); + 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]); + } + // 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('; '); + 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', '--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', '--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]); + // 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, trustedClone: clone, + clone: Object.freeze({ ...clone, directory: staging }), limits: Object.freeze({ ...limits }) })); + return filesystems; + } catch (error) { + 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; + } +} + +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/agents/contract.ts b/agents/contract.ts index dbf6731..b070931 100644 --- a/agents/contract.ts +++ b/agents/contract.ts @@ -53,6 +53,15 @@ 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(); +// 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 { + 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 +80,13 @@ 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 }), + 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; } /** Dispatcher predicate, not a sandbox. An adapter must enforce this externally. */ diff --git a/git/clone.ts b/git/clone.ts index cc9a0ca..edece0c 100644 --- a/git/clone.ts +++ b/git/clone.ts @@ -4,6 +4,27 @@ import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node: import { isAbsolute, join, relative, resolve } from 'node:path'; import type { TaskClone } from '../agents/contract.ts'; +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; + +/** 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; +} + /** * Prepare an independent committed snapshot. This is trusted staging, not the * writable execution filesystem: D2 must reserve bounded storage and separate @@ -79,7 +100,11 @@ 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 }); + 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 }); throw error; diff --git a/test/agent-container.test.ts b/test/agent-container.test.ts new file mode 100644 index 0000000..2478dcc --- /dev/null +++ b/test/agent-container.test.ts @@ -0,0 +1,482 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomBytes, randomUUID } from 'node:crypto'; +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'; +import { captureInvocation, type InvocationInput, type Phase } from '../agents/contract.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'; +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(); +const docker = (...args: string[]) => execFileSync('docker', args, { + encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], +}).trim(); + +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'); + 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, options.limits ?? { + 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 }; +} + +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() + 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 } }); +} + +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, + imageId, + codexAuthFile: vendor === 'codex' ? (options.codexAuthFile ?? data.fakeAuth) : undefined, + claudeToken: vendor === 'claude' ? options.claudeToken : undefined }); + profiles.push(base); + return base; +} + +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 }); + } +}, 120_000); + +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', ['set -eu', + '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('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', + 'set -eu; printf generated > /work/generated.txt; touch /tmp/old "$HOME/old"; printf first']))).toBe('first'); + const output = runContainer(profile(data, 'execute', ['sh', '-c', + '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', ['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('; ')])); + 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', ['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', + '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); + 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', imageId })).toThrow('only'); + expect(() => createContainerProfile({ invocation: invocation(data.clone, 'planning', 'claude'), + 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'], 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: [], 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(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); + 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)); + docker(...rogueArgs); containers.add(valid.name); + 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 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)) }); + 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('{}'); + expect(statSync(valid.codexAuthFile!).mode & 0o777).toBe(0o444); + writeFileSync(data.fakeAuth, '{}'); + + 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); + + 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', + ...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); + + 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); + + 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('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('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 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('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 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); + 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); + 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); + 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); + 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 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); + + 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', () => { + 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); + const untrustedDigest = `sha256:${'0'.repeat(64)}`; + expect(() => assertBuiltAgentImage(untrustedDigest)).toThrow('trusted validated builder'); + expect(() => prepareTaskFilesystems(data.clone, { + workBytes: 1024, workInodes: 16, metadataBytes: 1024, metadataInodes: 16, + }, untrustedDigest)).toThrow('trusted validated builder'); + 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'); + }, 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 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 }); + 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 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 }); + 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'); + }, 6 * 60_000); + } +}); 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);