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
32 changes: 30 additions & 2 deletions agents/container/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,10 @@ export interface TreeCheckOptions extends StorageScriptOptions {
*/
export interface TaskTreeCheck {
readonly base: string;
/** Every gitlink of `base`, each an empty directory now; each gets an empty read-only mount. */
/**
* Every gitlink of `base`, each an empty directory now; each gets an empty read-only mount, and every directory above
* one (as `gitlinkParents(gitlinks)` lists them) is pinned with a mount of its own.
*/
readonly gitlinks: readonly string[];
}
/**
Expand All @@ -389,8 +392,26 @@ export class TaskTreeRefused extends Error {
this.differences = Object.freeze([...differences]);
}
}
/** The most gitlinks one profile mounts; a head with more refuses the launch. */
/** The most gitlinks one profile mounts, and the most parent directories it pins; a head with more refuses the launch. */
export const MAXIMUM_GITLINK_MOUNTS = 256;
/**
* The most bytes of path the gitlink mounts add to the `docker create` arguments (each pin names its directory twice,
* each gitlink once), well inside what one exec can carry; a head that needs more refuses the launch.
*/
export const MAXIMUM_GITLINK_MOUNT_BYTES = 128 * 1024;
/**
* Every directory above a gitlink below the top level, parents before children (#99). Each is mounted again at its own
* path from the same work volume, so it is a mountpoint: renaming or removing it fails (EBUSY), and the gitlink's own
* mount cannot be moved away with it. What is inside stays writable.
*/
export function gitlinkParents(gitlinks: readonly string[]): readonly string[] {
const parents = new Set<string>();
for (const path of gitlinks) {
const parts = path.split('/');
for (let index = 1; index < parts.length; index++) parents.add(parts.slice(0, index).join('/'));
}
return Object.freeze([...parents].sort((a, b) => a.split('/').length - b.split('/').length || (a < b ? -1 : a > b ? 1 : 0)));
}
// The storage each check was made over, and whether a profile has used it.
const treeChecks = new WeakMap<TaskTreeCheck, { filesystems: TaskFilesystems; used: boolean }>();
// Docker reads `--mount` as comma-separated fields that a double quote can open: a name with either cannot be mounted
Expand Down Expand Up @@ -488,6 +509,13 @@ export async function checkTaskTree(storage: TaskFilesystems, options: TreeCheck
}
if (gitlinks.length > MAXIMUM_GITLINK_MOUNTS)
differences.push(`the head has ${gitlinks.length} gitlinks, more than the ${MAXIMUM_GITLINK_MOUNTS} that can be mounted`);
const parents = gitlinkParents(gitlinks as string[]).length;
if (parents > MAXIMUM_GITLINK_MOUNTS)
differences.push(`the head's gitlinks are below ${parents} directories, more than the ${MAXIMUM_GITLINK_MOUNTS} that can be pinned`);
const mountBytes = (gitlinks as string[]).reduce((total, path) => total + Buffer.byteLength(path), 0)
+ gitlinkParents(gitlinks as string[]).reduce((total, path) => total + 2 * Buffer.byteLength(path), 0);
if (mountBytes > MAXIMUM_GITLINK_MOUNT_BYTES)
differences.push(`the gitlink mounts would name ${mountBytes} bytes of paths, more than the ${MAXIMUM_GITLINK_MOUNT_BYTES} a launch passes`);
for (const path of gitlinks as string[]) if (unmountable(path)) differences.push(`gitlink ${q(path)} has a name Docker cannot mount`);
if (differences.length) throw new TaskTreeRefused([...new Set(differences)]);
const check = Object.freeze({ base: options.base, gitlinks: Object.freeze([...gitlinks as string[]]) });
Expand Down
13 changes: 11 additions & 2 deletions agents/container/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { join } from 'node:path';
import { assertCapturedInvocation, type InvocationInput, type Phase, type UnreleasedResource } from '../contract.ts';
import { assertBuiltAgentImage } from './image.ts';
import { assertTaskFilesystems, taskFilesystemOwner, type TaskFilesystems } from './storage.ts';
import { useTaskTreeCheck, type TaskTreeCheck } from './changes.ts';
import { gitlinkParents, useTaskTreeCheck, type TaskTreeCheck } from './changes.ts';
import { ownerLabelArgs, type ResourceOwner } from '../labels.ts';
import { assertVendorNetwork, removeVendorNetwork, vendorNetworkResources,
type VendorNetwork } from '../network/network.ts';
Expand All @@ -27,6 +27,8 @@ export interface ContainerProfile {
readonly deferredOutput: boolean;
/** Execute and fix: every gitlink path, each covered by an empty read-only tmpfs. Empty in read-only phases. */
readonly gitlinks: readonly string[];
/** Execute and fix: every directory above a gitlink below the top level, each remounted from the work volume (#99). */
readonly gitlinkParents: readonly string[];
}
export interface ProfileOptions {
readonly invocation: InvocationInput;
Expand Down Expand Up @@ -292,6 +294,7 @@ export async function createContainerProfile(options: ProfileOptions): Promise<C
// up: the next profile needs a new check, made just before its own launch.
if (readOnlyWork && options.treeCheck) throw new Error('Only an execute or fix profile takes a pre-launch tree check.');
const gitlinks = readOnlyWork ? [] : useTaskTreeCheck(options.treeCheck, filesystems, invocation.clone.head);
const parents = gitlinkParents(gitlinks);
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',
Expand All @@ -309,6 +312,12 @@ export async function createContainerProfile(options: ProfileOptions): Promise<C
'--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 }),
// Each directory above a nested gitlink is a real directory: the check proved it, and nothing writes the work
// volume between the check and this container's start (Docker would follow a link it found there). Mounting it
// again from the work volume makes it a mountpoint, which cannot be renamed, so the gitlink's mount cannot be moved
// aside and its path refilled during the run (#99). Docker mounts parents before children whatever the order here.
...parents.flatMap(path => ['--mount', mount({ type: 'volume', source: filesystems.workVolume, target: `/work/${path}`,
'volume-subpath': path })]),
// Each gitlink is an empty directory (the check proved it): an empty read-only tmpfs over it keeps it empty.
...gitlinks.flatMap(path => ['--mount', mount({ type: 'tmpfs', target: `/work/${path}`, readonly: true,
'tmpfs-mode': GITLINK_MOUNT_MODE, 'tmpfs-size': String(GITLINK_MOUNT_BYTES) })])];
Expand All @@ -329,7 +338,7 @@ export async function createContainerProfile(options: ProfileOptions): Promise<C
phase: invocation.phase, vendor: invocation.vendor,
filesystems: capturedFilesystems, inputDirectory: inputIdentity.inputDirectory, codexAuthFile,
command: Object.freeze([...command]), ownershipId, network: options.network, policy: options.policy,
deferredOutput: options.deferredOutput === true, gitlinks: Object.freeze([...gitlinks]) });
deferredOutput: options.deferredOutput === true, gitlinks: Object.freeze([...gitlinks]), gitlinkParents: parents });
identities.set(profile, Object.freeze({ inputDirectory: inputIdentity.inputDirectory, schema: inputIdentity.schema,
auth: authIdentity,
cleanupDirectories: Object.freeze([...cleanupDirectories]), filesystems, clone: invocation.clone,
Expand Down
25 changes: 23 additions & 2 deletions agents/container/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { agentContainerOwner, assertContainerProfile, assertContainerProfileAuth
type ContainerProfile } from './profile.ts';
import { BASE_IMAGE, CLAUDE_VERSION, CODEX_VERSION } from './image.ts';
import { taskFilesystemOwner } from './storage.ts';
import { gitlinkParents } from './changes.ts';
import { hasOwnerLabels, ownerLabels } from '../labels.ts';
import { assertPhasePolicy } from '../policy.ts';
export { prepareTaskFilesystems, removeTaskFilesystems, UnusableRepositoryError } from './storage.ts';
Expand Down Expand Up @@ -180,7 +181,8 @@ type Inspect = {
RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null; Runtime: string;
Devices: unknown[] | null; DeviceRequests: unknown[] | null; Tmpfs: Record<string, string> | null;
Mounts: Array<{ Type: string; Source: string; Target: string; ReadOnly: boolean;
TmpfsOptions?: { SizeBytes?: number; Mode?: number; Options?: unknown } }> | null; Dns: string[];
TmpfsOptions?: { SizeBytes?: number; Mode?: number; Options?: unknown };
VolumeOptions?: { Subpath?: string; NoCopy?: boolean; DriverConfig?: unknown; Labels?: unknown } }> | null; Dns: string[];
DnsOptions: string[]; DnsSearch: string[]; ExtraHosts: string[] | null;
PortBindings: Record<string, unknown> | null; PublishAllPorts: boolean };
Mounts: Array<{ Type: string; Name?: string; Source: string; Destination: string; RW: boolean }>;
Expand Down Expand Up @@ -256,7 +258,12 @@ export async function validateContainer(container: string, profile: ContainerPro
}
const mounts = new Map(inspect.Mounts.map(item => [item.Destination, item]));
const gitlinkMounts = profile.gitlinks.map(path => `/work/${path}`);
const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', ...gitlinkMounts,
// Derived again from the gitlinks, so the pins checked are exactly the ones those gitlinks need.
const pins = gitlinkParents(profile.gitlinks);
if (JSON.stringify(pins) !== JSON.stringify(profile.gitlinkParents))
throw new Error('A directory above a gitlink is not pinned by its own work-volume mount.');
const parentMounts = pins.map(path => `/work/${path}`);
const allowedMounts = new Set(['/work', '/work/.git', '/run/codeboost-input', ...gitlinkMounts, ...parentMounts,
...(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.');
Expand All @@ -272,11 +279,25 @@ export async function validateContainer(container: string, profile: ContainerPro
&& !(Array.isArray(requested[0]!.TmpfsOptions?.Options) && (requested[0]!.TmpfsOptions!.Options as unknown[]).length === 0))
throw new Error('A gitlink path is not covered by its empty read-only mount.');
}
// Every directory above a nested gitlink is the task's own work volume again, writable, at exactly its own subpath.
for (const path of pins) {
const target = `/work/${path}`;
const actual = inspect.Mounts.filter(item => item.Destination === target);
const requested = (host.Mounts ?? []).filter(item => item.Target === target);
const options = requested[0]?.VolumeOptions;
if (actual.length !== 1 || actual[0]!.Type !== 'volume' || actual[0]!.Name !== profile.filesystems.workVolume
|| !actual[0]!.RW || requested.length !== 1 || requested[0]!.Type !== 'volume'
|| requested[0]!.Source !== profile.filesystems.workVolume || requested[0]!.ReadOnly || options?.Subpath !== path
|| options.NoCopy)
throw new Error('A directory above a gitlink is not pinned by its own work-volume 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]));
// /work is the whole work volume: a subpath there would put another directory's content at the root.
if (requestedMounts.get('/work')?.VolumeOptions?.Subpath) throw new Error('Container mounts do not match the phase isolation profile.');
const requestedInput = requestedMounts.get('/run/codeboost-input');
if (requestedInput?.Type !== 'bind' || canonicalDockerBindSource(requestedInput.Source) !== profile.inputDirectory
|| canonicalDockerBindSource(input.Source) !== profile.inputDirectory
Expand Down
11 changes: 10 additions & 1 deletion agents/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,16 @@ export function createIsolationProbeCommand(policy: PhasePolicy, probe: Isolatio
+ 'n=$((n+1)); test "$(findmnt --noheadings --output TARGET --target "/work/$p")" = "/work/$p"; '
+ 'findmnt --noheadings --output OPTIONS --target "/work/$p" | tr , "\\n" | grep -Fxq ro; '
+ 'test -z "$(ls -A "/work/$p")"; deny touch "/work/$p/x"; deny mkdir "/work/$p/.git"; '
+ 'deny rmdir "/work/$p"; deny mv "/work/$p" "/work/$p.moved"; done; test "$n" -gt 0; '
+ 'deny rmdir "/work/$p"; deny mv "/work/$p" "/work/$p.moved"; '
// Each directory above it is pinned (#99): its own writable nosuid,nodev mountpoint, which cannot be moved aside,
// so the gitlink's mount cannot be carried off and its path refilled. What is inside stays writable.
+ 'd=$(dirname "$p"); while [ "$d" != . ]; do '
+ 'test "$(findmnt --noheadings --output TARGET --target "/work/$d")" = "/work/$d"; '
+ 'o=$(findmnt --noheadings --output OPTIONS --target "/work/$d" | tr , "\\n"); '
+ 'for x in rw nosuid nodev; do printf "%s\\n" "$o" | grep -Fxq "$x"; done; '
+ 'deny mv "/work/$d" "/work/$d.moved"; test -d "/work/$p"; '
+ 'touch "/work/$d/.pinned-write"; test -f "/work/$d/.pinned-write"; rm "/work/$d/.pinned-write"; '
+ 'd=$(dirname "$d"); done; done; test "$n" -gt 0; '
+ 'printf ok > /work/beside.txt; printf gitlink-protected',
};
return command(policy, probe === 'noop' ? ['true'] : ['sh', '-c', scripts[probe]]);
Expand Down
16 changes: 11 additions & 5 deletions docs/implementation/agent-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Each row is a T9 requirement for the Docker suite. The suite fails if any row fa
| Hard links and alias writes from `.git/config` and objects fail, and metadata stays unchanged | `agent-container`: metadata alias probe in planning, review and execute, with a digest of `.git` before and after |
| Mountpoint replacement fails | `agent-container`: metadata and metadata alias probes (`mv` and `rm -rf` of `.git`) |
| Both vendor startup probes read the schema and return bounded valid output through their documented channel | `agent-supervisor` live probes (credentials required): Codex in review through its output file; Claude in questions through its stdout envelope; Claude in planning returns a schema-constrained answer as bare JSON. **Known failure:** the Codex probes (here and in `agent-container`) fail, because Codex cannot read files in any phase (see [Codex in read-only phases](#codex-in-read-only-phases)). |
| Nothing can be written beneath a gitlink, and execute and fix launch only on a clean tree (#81) | `agent-container`: the gitlink probe (writes, `mkdir`, `rmdir` and `mv` at every gitlink fail; the rest of `/work` stays writable); a pre-populated nested checkout, a restart with an untracked symlink parent and a restart with an occupied add destination are refused; a gitlink name Docker cannot mount, more than 256 gitlinks and an unreadable tree are refused; each check serves one profile over its own storage and head; the validator rejects a gitlink mount that is missing, writable, or of another size or mode |
| Nothing can be written beneath a gitlink, and execute and fix launch only on a clean tree (#81) | `agent-container`: the gitlink probe (writes, `mkdir`, `rmdir` and `mv` at every gitlink fail; the rest of `/work` stays writable); a pre-populated nested checkout, a restart with an untracked symlink parent and a restart with an occupied add destination are refused; a gitlink name Docker cannot mount, more than 256 gitlinks and an unreadable tree are refused; each check serves one profile over its own storage and head; the validator rejects a gitlink mount that is missing, writable, or of another size or mode; every directory above a nested gitlink is pinned, writable and `nosuid,nodev`, so renaming one fails (#99); the validator rejects a pin that is missing, read-only, at another subpath, from another volume, `volume-nocopy`, or not a volume |
| Hostile input stays inside the boundary | `agent-container`: repositories with links that leave the checkout are refused, links inside the checkout still work, oversized repositories fail closed; `agent-policy`: option-like prompts; `agent-proxy`: hostile CONNECT traffic; `agent-supervisor`: hostile output |

## Why the gate can fail
Expand Down Expand Up @@ -126,7 +126,16 @@ Use only these entry points to run an agent:
Pass the result as `treeCheck` in the start call's request. An execute or fix profile requires it; a read-only
phase refuses one. The profile adds an empty read-only tmpfs (`--mount type=tmpfs,target=/work/<path>,readonly,
tmpfs-mode=0555,tmpfs-size=4096`) at every gitlink the check listed, and the validator allows exactly those mounts,
each read-only with that size and mode. A check serves one profile, over the storage and head it was made for: a
each read-only with that size and mode. Every directory above a gitlink below the top level is pinned (#99): the
profile mounts the work volume again at that path (`--mount type=volume,source=<work volume>,target=/work/<dir>,
volume-subpath=<dir>`, which needs Docker 26 or later). A mountpoint cannot be renamed, so the gitlink's mount
cannot be moved away with its parent and the path refilled; what is inside stays writable and `nosuid,nodev`, on
the same volume and limits. A pin is a mount boundary: `rename(2)` and hard links across it fail with `EXDEV`, so
`mv` copies instead, and a tool that writes a temporary file elsewhere and renames it into a pinned directory
fails. Docker resolves the subpath when the container starts, following any link there, so nothing may write the
work volume between the check and the start (nothing does). More than 256 pinned directories, or more than
128 KiB of paths in these mounts, is refused. The validator allows exactly these mounts: the task's own
work volume, writable, at that subpath. A check serves one profile, over the storage and head it was made for: a
copy, a second use, other storage or another head is refused, so check again before every invocation, retries
included.
- **After the handle settles**, call `inspectTaskChanges(storage, { base, linkSnapshot, imageId })`. It returns the
Expand Down Expand Up @@ -382,6 +391,3 @@ against real Docker, including two reviews with different Ask owners on one daem
is a read-only volume, so nothing can be written beneath a gitlink there; that includes `check` attempts, which run
as review. Running an item's `cmd` in a writable task filesystem is not built yet; when it is, each run must call
`checkTaskTree` and get the same mounts before it starts.
- The mount keeps a gitlink directory empty, but an agent can still rename a parent directory of a nested gitlink
(the mount moves with it) and create a new directory at the old path. The inspection after the run reports that
directory's content as nested gitlink content, so the task goes to needs human. #99 tracks enforcing this during the run.
Loading
Loading