Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/agent-isolation.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions agents/container/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
40 changes: 40 additions & 0 deletions agents/container/image.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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<string, string> } };
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;
Comment thread
mchwang marked this conversation as resolved.
}
84 changes: 84 additions & 0 deletions agents/container/probe.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
Loading
Loading