Isolated command checks
Experimental (alpha). This part of AgentSCM is not part of the main path, may change without migration, and has had limited testing. See the advanced index.
Isolated command checks run through the Unix-host execution Rust library module, which collects a command result for an exact CheckSubject. It is separate from the built-in source_paths/v1 check; the durable command/v1 check below connects it to the authority. The collector itself does not install policy, issue signatures, grant approval or move targets. The controller and its local filesystem, Docker executable, daemon and acquired image are trusted.
Recipe and source
CommandRecipe fixes a local immutable sha256: image ID, absolute executable and argument vector, explicit environment, toolchain digest and limits. Canonical JSON has fixed struct field order and sorted environment keys. SHA-256 identifies recipe bytes. Request digests additionally use the agentscm.execution-request.v1 domain and bind the attempt ID, current running generation, repository, Change version, context, revision, recipe, toolchain and environment. Changing any binding requires a new request. The environment digest hashes canonical JSON of the recipe environment; fixed controller HOME=/scratch and TMPDIR=/scratch are part of this execution profile. The image’s environment may contain only matching declared entries. Toolchain identity is bound, not independently discovered inside the image.
There are at most 64 arguments and 16 KiB of argument text, 32 environment entries and 16 KiB of environment text. Loader, shell-startup and Docker control environment names are rejected. Environment values are ordinary recipe data, never inherited host secrets. Operators must review them for intentional disclosure; this module does not classify secret values.
Snapshot::materialize reads one immutable native revision into a new controller-owned directory outside repository authority and credentials. It rejects existing destinations and unsafe mount delimiters. Snapshot files and directories are frozen for read-only access by UID/GID 65532. The containing directory is checked for owner-only Unix mode bits and the same owner as the new snapshot. It must remain private and owned by the controller; untrusted host processes may not mutate snapshots or runtime configuration. Drop attempts to remove the snapshot; a host filesystem failure may require operator cleanup. Explicit snapshot bounds are at most 10,000 files and 256 MiB. One native file read can allocate the source engine’s 64 MiB limit before the aggregate bound is checked. This is not the large-repository execution profile.
Runtime and bounds
OciBackend::new takes an absolute trusted executable, existing local Unix socket and an empty private configuration directory. It clears the Docker CLI environment, sets an isolated HOME/config directory and uses an explicit Unix endpoint. It does not inspect contexts, authenticate, pull images, read registry credentials or mount a host socket into a candidate.
The probe requires a Linux daemon with cgroup v2, reported memory/swap/CPU/PID limit support, runc and the built-in seccomp profile. The retained runtime identity includes a stable daemon identifier, observed Docker build/version and a digest of selected kernel, capability, runtime and server-component data. It is checked again before start and after normal completion; a mismatch cannot produce successful evidence. The actual create request names runc and seccomp=builtin; the effective container configuration must retain those settings. This identifies the reported built-in profile under that runtime build. It is not a hash of exported kernel filter bytes or proof that a patched/malicious daemon enforces its report. The daemon remains trusted. Hostile live canaries on the supported runtime are a separate mandatory qualification gate.
Before starting the container, the collector inspects the effective configuration. It requires non-root UID/GID, no network, a read-only root and source mount, no other source/host/secret/device mounts, dropped capabilities, no-new-privileges, built-in seccomp, private process/cgroup namespaces, no shared IPC, bounded scratch tmpfs, no image-declared volumes, no health command and no persistent logging. Rejected or unconfirmed configuration cannot start candidate code. Docker create warnings fail closed.
| Bound | Controller range |
|---|---|
| Command wall time | 1 ms to 5 min |
| Aggregate retained stdout/stderr | 256 B to 1 MiB |
| Memory and memory-plus-swap | Same bound, 16 MiB to 4 GiB |
| Scratch tmpfs | 1 MiB to 1 GiB |
| Process count | 1 to 256 |
| CPU rate | 0.1 to 4 cores |
Each control operation has a five-second deadline, except docker create, which gets 30 seconds because a cold first create unpacks image layers, and a 256 KiB aggregate output bound. Command time excludes bounded acquisition, inspection and cleanup calls. Capture requires end-of-stream on both outputs; a writer retained after the CLI exits remains incomplete. An observed timeout, cancellation or output limit, including a timeout reached during the post-exit drain, takes precedence over a later incomplete capture. The backend retains capture incomplete in its detail, including when cleanup is unconfirmed. Local validation retains the independent incomplete flag; false is omitted for compatibility with older records, while older strict readers reject records containing the new true flag. The supervisor kills the Docker client on cancellation, timeout or excess output; it then removes the uniquely named, request-labelled container to terminate its whole process tree. It checks ownership before deletion and requires a separate confirmed absence. A transport failure never proves removal. Unconfirmed cleanup returns indeterminate with a structured CleanupHandle binding the physical name, immutable container ID when known, request label, runtime identity and whether creation was confirmed. The bounded reconcile(request, &mut handle) call brackets cleanup with matching runtime identity checks, validates ownership and deletes only by a full immutable container ID. A replacement daemon or reused name cannot prove cleanup of the original incarnation. A handle with unknown physical identity cannot be closed from name absence alone: a late create or renamed container may still exist. If reconciliation discovers an unknown ID, it updates the handle and returns without deletion. The controller must persist that handle before calling again; a run whose create reply was lost does this itself, persisting the discovered ID through its custody callback and reconciling once more, so a create that has completed and is visible by its owned name at that final reconciliation is removed in the same run and ends incomplete. A create not yet visible, a failed persist or an unconfirmed removal leaves the run indeterminate for recovery; uncertain removal then retains the known physical identity for retries. Retry/recovery must reconcile it before declaring termination. A controller process crash can leave a container; the durable command/v1 check persists custody for operator recovery, but unattended service recovery is still required.
Outcomes and admission
The controller derives outcomes from the attached output and daemon-observed stopped state/exit status, never from a worker-produced success JSON. A completed exit zero may produce success only with matching effective configuration, no output/transport failure and confirmed removal. Nonzero exit is failed; OOM, a SIGKILL exit status (137), invalid state or incomplete capture is incomplete. A memory-limit kill of a child process makes the shell exit 137 while Docker’s asynchronous OOMKilled flag may still be false, so 137 alone is classified incomplete, whatever sent the signal. Timeouts, cancellation, output-limit stops and unavailable runtime/image remain distinct. Missing identity or uncertain cleanup cannot produce success.
Records are ordinary data. Deserializing a record or validating its digest does not authenticate the collector. Do not accept externally supplied records as trusted execution evidence. Installed policy, issuer trust, actual isolation qualification, source/model coverage, freshness, retention, signatures and integration permission remain separate facts.
Durable command/v1 check
A context requests the check with capability agentscm.checks.command/v1 and required evidence command/v1 whose digest equals the context recipe and CommandRecipe::digest(). The context toolchain and environment digests must equal the recipe’s. Unix hosts only; other platforms reject the capability. Schema v5 adds command_attempts and command_evidence; v6 adds cleanup custody to command_attempts.
agentscm check-command registers an attempt for the current Change version, context and revision; the same attempt ID with identical input returns the same attempt, different input conflicts. agentscm check-run-command <attempt> <generation> --docker <exe> --docker-socket <sock> claims the pending attempt by compare-and-swap (revalidating the current context and atomically requiring its policy epoch to match the authority), materializes the revision into <data-dir>/execution/snapshot-<attempt>-<claimed generation> (owner-private; created if missing), runs the collector with a fresh empty Docker config directory, and stores the record by compare-and-swap against the claimed generation. A snapshot failure stores an incomplete record. agentscm check-command-show <attempt> prints the attempt and its record.
Approval and integration of a context requiring the check succeed only if a finished attempt for that exact Change version, context, revision, recipe, toolchain and environment has a stored record that re-parses canonically and is success with observed isolation, confirmed cleanup, no cleanup handle and the same attempt ID, run generation, subject and recipe. Nothing imports an external record or attempt state.
Agent candidates: a container sees only /source read-only and a tmpfs /scratch that Docker cannot copy out, so an agent hands back its result on stdout as exactly one JSON document, agentscm.candidate/v1: {"version":1,"changes":[{"path":"dir/a.txt","hex":"68690a"},{"path":"old.txt","hex":null}]}. hex is lowercase, even-length hex of the file bytes and null deletes. Unknown fields, trailing output, an empty list, duplicate paths and uppercase or odd hex are rejected. The parser also rejects absolute paths, ./../empty components, backslashes, NUL and any component reserved by Git export and workspaces (.git, .agentscm, .agentscm-workspace, agentscm-data) in any case, and Engine::checkpoint applies its own path, count and size limits. A busybox serializer is od -An -tx1 -v FILE | tr -d ' \n' per file. agentscm check-command-candidate <attempt> derives the candidate only from a finished attempt whose stored record passes the same checks as admission (success, observed isolation, confirmed cleanup, no handle, matching attempt and subject) and is not truncated; it never reads a worker-supplied record. The revision is Engine::checkpoint(subject revision, changes), so its parent is the run’s exact subject revision. It is content-addressed: the same record always yields the same revision, so that reproducibility is the binding and no table stores it. A candidate that leaves the tree unchanged is refused; failed or no-op candidates may leave unreferenced source objects. Any successful attempt’s stdout can be offered; the operator’s explicit check-command-candidate call is the opt-in. Nothing lands by itself. The run’s context names the subject revision, so it can never approve or integrate the candidate; landing needs update_change_context to a new context on the candidate revision and a new approval, and if that context requires command/v1, a fresh run against the candidate revision. Limits: stdout and stderr share max_output_bytes (at most 1 MiB), a truncated or output-limited run yields no candidate rather than a smaller one, and hex doubles the size, so changed content is at most about half the bound. There is no binary streaming. The agent is untrusted, so a candidate is proposed source, not evidence that it is correct.
Cancel and recovery: before docker create, and again once the container ID is known, the run persists the CleanupHandle as custody on the running attempt (custody_json), in the same compare-and-swap that guards the claimed generation. A failed persist starts nothing. agentscm check-cancel-command <attempt> <generation> <reason> cancels a pending or running attempt; check-recover-command (same arguments) marks a running one incomplete. Both take optional --docker/--docker-socket, required together and whenever the attempt holds custody. A pending attempt becomes cancelled at generation + 1 with no runtime. A running attempt is first fenced by bumping its generation, so a late finisher’s store fails and cannot overwrite recovery; a stale runner that has not started is refused at its next custody write and starts nothing; a started one is stopped by recovery’s forced removal, and its store returns the already-finished attempt. Recovery then reconciles the custody handle and finishes only after a container removal is confirmed, removing the attempt’s stale snapshot-<attempt>-<generation> directories. Unconfirmed cleanup (or a lost removal reply during a run, which returns indeterminate to the collector) leaves the attempt running with the latest handle; retry at the new generation the error names. Custody with no known container ID cannot be closed from name absence: reconciliation may learn the ID, persist it and still report unconfirmed, and an unanswered create may complete later, so the operator must investigate the daemon. After that, check-recover-command --abandon-unconfirmed finishes the attempt as indeterminate with the handle retained in its record, which never admits; its snapshot directory is kept because a container may still mount it. Run-time Ctrl-C or SIGTERM cancels the collector, which removes the container and stores a cancelled record. These records never admit.
Remaining limits: nothing recovers an unattended or crashed run automatically; an operator must run check-recover-command. docker-config-* directories left by SIGKILL are not swept, and a snapshot failure is a terminal incomplete record, so retry with a new attempt ID. Recovery without custody assumes no container was requested; upgrading to schema v6 therefore refuses while any v5 attempt is still running. One context recipe cannot satisfy both source_paths/v1 and command/v1, so a context requiring both is never admitted. Records are unsigned; a writer to state.sqlite can forge them, so the local owner and Docker daemon stay trusted. No HTTP route, installed policy or issuer trust.
Qualification
Ordinary tests use /usr/bin/python3 for a fake Docker transport fixture; production execution/controller code is Rust. They exercise synthetic control transport, configuration rejection, output/deadline/cancellation behavior, subject binding and snapshot safety. They execute no candidate code on the host. Passing them establishes orchestration behavior only.
The opt-in live test requires an explicitly selected local Docker executable/socket and already acquired immutable Linux image containing /bin/sh and /bin/busybox. Missing prerequisites fail an explicitly requested test; they are never silently skipped into success. No image is pulled. The tests log the daemon/image/platform identities, hostile-control output and cleanup observations; CI keeps them only in the job log. A successful fixed canary corpus qualifies only that runtime/profile and those controls; it cannot establish arbitrary hostile-code safety.
tests/execution_canaries.rs is the live hostile-boundary corpus. Each canary runs a fixed script through OciBackend::collect. A hostile action must fail with its specific error (for example Network is unreachable or No space left on device), not merely exit nonzero; the script exits 0 only if every one did and the test requires success, or else the specific collector outcome. After every run the test checks confirmed cleanup, no cleanup handle and no remaining container labelled for that request, and logs the outcome, exit code, runtime identity and output.
| Canary | Observes |
|---|---|
network_egress_is_blocked | Direct and DNS egress fail; no non-loopback route; no interface other than loopback is UP |
filesystem_is_read_only_except_bounded_scratch | / and /source are read-only mounts (asserted from /proc/self/mountinfo); /scratch is nosuid,nodev. Writes to /source, /, /tmp, /etc and /usr fail, by the read-only mount and, for /source and system directories, by mode bits too; remount of / fails; source content is intact; /scratch is writable and a write beyond scratch_bytes fails |
no_privileges_secrets_or_host_sockets | UID/GID 65532, empty capability sets, no-new-privileges, seccomp filter mode; exact environment keys and HOME/TMPDIR/PATH values; a bounded search finds only planted decoys (so no Docker socket, *.sqlite or .agentscm is visible); mount, chroot and user/mount namespace creation fail with EPERM; nofile 128 and core 0 |
process_count_is_bounded | A fork flood stays within pids; the cgroup pids.max is visible and equals it |
devices_mounts_namespaces_and_cgroups | Every mount is read-only or an expected one (scratch, Docker’s /dev, /dev/pts, /dev/mqueue, /proc and its file masks); no /dev/shm, block device, /dev/mem, /dev/kmsg or /dev/fuse; mknod, hostname, date -s and net/pid/ipc/uts namespace creation fail; cgroup memory.max, memory.swap.max, cpu.max and pids.max match the recipe and the cgroup path is 0::/; the shell is PID 1 and sees almost no other process; /proc/kcore reads empty and sysrq-trigger and /proc/sys are not writable |
memory_exhaustion_is_killed_not_success | Exceeding the memory bound is incomplete (OOM kill, exit 137, shell reports Killed), never success |
output_flood_stops_at_limit | An unbounded writer ends as output_limit with retained output at or below the bound |
timeout_kills_the_whole_process_tree | A timeout yields timed_out and the container, with every descendant, is removed |
The /proc and /sys masks are defence in depth behind UID 65532 and dropped capabilities, so some of those checks pass for more than one reason. Scratch is mounted nosuid,nodev by the collector; noexec is Docker’s default and is logged but not asserted. Ubuntu’s AppArmor docker-default profile is part of the runner’s runtime and may block some actions a second time; the canaries do not isolate it. The seccomp filter’s contents (for example keyctl, bpf, ptrace, userfaultfd) and AppArmor are not canaried, because busybox has no helper that issues those syscalls; only the filter’s presence is observed.
CI (.github/workflows/execution-live.yml) runs the corpus on the runner’s own Docker and logs docker version and key docker info fields. A pass qualifies only that runtime build, image and this execution profile against these controls. It does not cover kernel or runc escapes, side channels, host-level resource contention, other runtimes or arbitrary hostile code.
Required remaining work includes automatic service recovery for command/v1, installed-policy integration, the real bounded model adapter and quality corpus, authenticated portable evidence and independent verification. The trusted-local boundary remains until those gates pass.