Development record
a-person-who-leaves-abcd-to-run-on-its
Summary
This spec delivers part A of itd-2610161621364075: every agent abcd's runner launches runs inside a fence the operating system enforces around the whole launched process. It enacts adr-2610161621415708. Part B (reporting how isolated a person's own session is) and part C (the network promise) are separate records, by the intent's decision 1.
The launches it covers are the ones that pass through the runner's one launch point, launcher.run in internal/core/runner/proc.go:
- the loop's roles (implementer, reviewers, auditor) started by
implement/loop.Drive, forbuildand for eachdrainlane; - the planning interviewer started by
interview.Written; - a role that falls back to
runner.fallback_host, which is another runner launch.
runner.Exec, which runs a vendor binary for abcd's own short calls such as a quota reading, starts no agent and is not fenced here.
Building waits on two records, by decision 5: the worktree store (itd-2609091014076309) and the guard's per-launch approver (iss-2610161612203555, on branch feat/headless-approvals with its decision record). Steps 3 to 10 and 12 to 14 can be built before the approver lands. Step 11 cannot. Step 12, the load gate read outside the fence, does not depend on the fence at all and can land first. Nothing is built before step 2, where the product thinker answers the questions listed under Questions for the product thinker below.
Which fence
Ruling 6 asks for Anthropic's sandbox while abcd launches Claude, prefers a state-of-the-art open-source fence abcd adopts, and rules out a fence of abcd's own. Planning was to check first whether Anthropic's sandbox already meets that preference for every harness. It does, and it is the recommendation for both runners.
Anthropic's sandbox-runtime (srt) (repository, read 2026-10-16; npm @anthropic-ai/sandbox-runtime 0.0.79, published 2026-10-07):
- Apache-2.0. It is the code Claude Code's own shell sandbox is built on, and the vendor's guide names it as the way to put a whole Claude Code process behind one boundary, file tools, MCP servers and hooks included (sandbox environments, sandboxing).
- It wraps any process: its README names "agents, local MCP servers, bash commands and arbitrary processes". So the same fence serves opencode, which settles how opencode launches are fenced, the question decision 6 left open.
- On macOS it runs
sandbox-execwith a Seatbelt profile it generates. On Linux it runs bubblewrap without a network namespace, bridges a host-side proxy in oversocat, and adds a seccomp filter that blocks creating Unix sockets. - Writes are denied everywhere by default, and a deny wins over an allow. Reads are allowed everywhere by default.
- Some writes are denied even inside an allowed path:
.git/hooks,.git/config, the shell's startup files,.gitconfig,.gitmodules,.mcp.json,.claude/commandsand.claude/agents, from the wrapped process's directory down. - A settings file that is named but missing, empty or invalid makes it refuse to start rather than fall back to its defaults.
- Maturity: the README calls it a "Beta Research Preview" whose "APIs and configuration formats may evolve". It needs Node 22.12 or later. On Linux it also needs bubblewrap 0.4 or later,
socat,ripgrep, and unprivileged user namespaces; Ubuntu 24.04 restricts those by default, which is the intent's fifth scope condition.
The other open-source options, against the same need (a whole-process boundary on macOS and Linux that abcd adopts rather than writes):
- nono (always-further/nono): Apache-2.0, a single Rust binary, Seatbelt on macOS and Landlock on Linux, with profiles for Claude Code and OpenCode. It is the nearest rival and needs no user namespaces. It is pre-1.0 ("APIs are stabilizing"), it is not Anthropic's, and ruling 6 asks for Anthropic's while abcd launches Claude. Two fences for two runners would be two policies to keep in step (
one-canonical-primitive). It stays the first alternative if srt fails the probes in step 1, and the fence seam below makes it a second adapter rather than a rewrite. - landrun (Zouuup/landrun) and go-landlock (landlock-lsm/go-landlock): MIT, Go, Landlock only. That makes them Linux only, with no macOS half. Linux 5.13 or later is needed for files and 6.7 for TCP. Landlock needs no user namespaces, which is its real advantage over bubblewrap. Using the library would leave abcd writing its own policy layer, and ruling 6 rules out a fence of abcd's own.
- bubblewrap (containers/bubblewrap): LGPL-2.1+, mature (Flatpak, rpm-ostree), Linux only. By its own README it "is not a complete, ready-made sandbox with a specific security policy". It is the building block srt already uses.
- nsjail (google/nsjail): Apache-2.0, Linux only, namespaces and seccomp configured in protobuf. It is built to jail services and fuzz targets, needs user namespaces as bubblewrap does, and has no macOS half.
- firejail: GPL-2.0 and a setuid-root binary. Its attack surface is the reverse of what this record wants, as CVE-2022-31214 showed: a local root escalation through its join path (Debian tracker).
sandbox-execwith a profile of abcd's own: no dependency, but abcd would author and maintain the Seatbelt policy, which is the fence of its own ruling 6 rules out. Its man page also marks the command "DEPRECATED". Every macOS option above runs on the same mechanism, so this deprecation is a risk they share, recorded in the ADR's consequences.- Codex's sandbox: Seatbelt on macOS and bubblewrap or Landlock on Linux, Apache-2.0. It ships inside the Codex CLI, so adopting it would mean installing another vendor's agent. It is the candidate to reconsider when abcd launches Codex, by decision 6.
Recommendation: srt for both the claude and the opencode runner, on macOS and Linux. It is the fence ruling 6 names for Claude, it already wraps any process, and one fence means one policy derivation. The choice is recorded in dcn-2610161809259623.
prefer-sota asks for an adversarial fit-challenge before the verdict, and one was run on 2026-10-16. It kept srt for both runners, since most of what it found about opencode comes from opencode's own state and would hit any fence. It changed the design in six places, each written in below:
- the main git directory is narrowed, with a ref check after each launch;
- srt's interpreter and package are admitted, not only its script;
- the prompt's passage through srt's shell is tested;
- srt's leftovers are cleared after a killed launch;
- opencode's state is seeded rather than dropped;
- reads of the credential stores a launch never needs are denied.
It also named two things to probe rather than assert, the macOS hard-link gap and the harnesses' behaviour under a fresh state folder (step 1). nono is reopened only if a probe finds a gap in srt that cannot be worked around.
An advisor's fit review the same day kept the pick again and changed the design in eight more places, each written in below: Go's checksum database and private modules, commit signing, automatic gc, the fence as a seam, the version policy, the approver's executable, srt as adr-22's first declared exception, and the order and split of the steps.
The dependency. abcd does not vendor srt and go.mod does not change. The person installs it once, like the harness it wraps. It is a new external program on the runner route, and on that route it is a requirement, not an adapter that degrades. adr-22 makes every external tool an opt-in adapter over a native default that a missing tool degrades to. A fence has no native default to degrade to: abcd writes no fence of its own (ruling 6), and an unfenced launch is the very thing the intent removes. So srt is framed as adr-22's first declared exception, scoped to the runner route alone. Everywhere else abcd still needs no external program, and in place of degradation the person has the per-machine opt-out, runner.sandbox.off (ruling 7). The exception is written as a consequence on adr-22 and as one paragraph in the dependencies chapter (02-constraints/02-dependencies.md), both signed off by the product thinker at step 2 on 2026-10-22 (question a, dcn-2610222041417934).
What the fence brings that part A did not ask for. srt filters the network to a list of hosts, and it refuses * in that list ("Overly broad patterns like ".com" or "" are not allowed for security reasons", sandbox-config.js in 0.0.79). So a fenced launch reaches only the hosts it is given, whether or not part C has been built. The list here is derived for function, not as part C's promise:
- the runner's model endpoint:
api.anthropic.comfor claude, or the host ofANTHROPIC_BASE_URLwhen it is set; for opencode, the endpoint of the provider its model route names; - the hosts of
GOPROXYandGOSUMDBwhen the worktree holds ago.mod; - with a
go.mod, the hosts named byGOPRIVATEandGONOPROXY. Go fetches those modules straight from their version-control host, past every proxy, the file proxy below included, so without the host a private module cannot be fetched at all. The host part of each pattern is taken. A pattern whose host srt refuses as too broad is named at the check, and the person adds the concrete host inrunner.sandbox.hosts; - the hosts the person adds in
runner.sandbox.hosts, in their machine layer only.
A lane's agent never pushes: its brief says "Never push", and the land stage pushes from abcd, outside the fence (landPush, internal/core/implement/loop/land.go). So the forge is not on the list: only abcd's own push, outside the fence, reaches it. The product thinker chose this derived list at step 2 on 2026-10-22 (question c, dcn-2610222041427241). The run record's fence block keeps the list; the run does not print it at its start.
The fence is a seam
The fence sits behind a seam, as the brief's rule for every capability taken from an external tool asks (internals § 7, the paragraph that opens "Each seam is a Go interface"): a Go interface, consumers that depend on the interface and never on a vendor, a backend selected by configuration, and a new backend added by implementing the interface and registering it, with no edit to the consumers. A new package, internal/core/fence, holds:
Policy, which is fence-neutral: the paths a launch may write, the paths it may not read, and the hosts it may reach. Steps 3 and 13 derive it, and nothing in it names srt.Fence, the interface each mechanism implements:Checkadmits the mechanism, reads its version and makes a trial launch, and returns the evidence: the admitted paths, the version, whether that version is one abcd was tested against, and the trial's two writes;Wrap(policy, argv, env)returns the argv and the environment to start, and a cleanup to run after the launch ends, however it ends;Refusals(stderr, transcript)reads the mechanism's refusals back out of a finished launch.
internal/core/fence/srt, the one adapter. It writes srt's settings file from aPolicy, builds srt's argv, and parses srt's refusals.
The adapter is chosen by runner.sandbox.backend, in the machine layer only like the rest of runner, and its default is srt. Two departures from the rule are deliberate. There is no native default to fall back to, so a missing or failing backend refuses the run (ruling 7) rather than degrading. And the interface lives in internal/core/fence, not under internal/adapter/, because deriving a policy from a launch is core logic the runner and the loop share; the adapter sits beneath it. nono, the named alternative, or Codex's sandbox once abcd launches Codex, would be a second adapter, and neither the runner nor the loop would change.
What a launched agent may write
The write list is derived from the launch itself; nothing in the repository widens it. Every other path is read-only. On Linux srt takes literal paths only, so each entry is resolved to a real absolute path before the settings file is written (fsutil.EnsureRealDirAll, so a symlinked component is refused).
- The worktree the role runs in (
Request.Dir). For a lane that is~/.abcd.noindex/worktrees/<root-sha>/<run-id>-<lane-id>. For the planning interviewer, which runs in the checkout, it is not the checkout: the interviewer gets the folder holding the intent record it is editing, and the interview's changed-paths check keeps refusing every other change, as today. - The receipt folder: the lane's directory
.abcd/.work.local/run/<run-id>/<lane-id>/(itsbrief.mdandreceipt.json), or the interview's turn directory. The run'sstate.json, its lock and its fingerprint stay readable and are not writable. - The main checkout's git directory, narrowed to what a commit in the lane writes:
objects/;- the lane's own
worktrees/<name>/, with itsconfig.worktree,gitdirandcommondirdenied; - the lane branch namespace
refs/heads/build/and its reflogs underlogs/refs/heads/build/.
Nothing grants hooks/, config, packed-refs, HEAD, the main index or any other worktree's folder, so what decides what git runs, and every branch outside the loop's namespace, stays out of reach. srt's own .git denies do not cover this: they reach from the wrapped process's directory down, and in a linked worktree .git is a file pointing at the main one.
On Linux srt grants folders, not single files, and build/ holds every lane's branch. So after each launch abcd compares every ref, read outside the fence, with the list it took before. A launch that moved any ref but its lane's own branch is recorded, and its lane refuses to land (step 9).
- A fresh Go cache per run, by ruling 8:
GOPATH,GOCACHEandGOMODCACHEpoint into the run's fence directory.GOPATHmoves with the caches becausegowrites its checksum-database cache to$GOPATH/pkg/sumdb/, outsideGOMODCACHE: with the person'sGOPATHread-only, a build that must verify a module not yet ingo.sumfails. The move also sendsgo install's binaries to the run's$GOPATH/bin, inside the fence. The module cache is filled from the person's own cache throughGOPROXY=file://<their GOMODCACHE>/cache/download,<their GOPROXY>, a formgo help goproxydocuments. A cached module is copied in without the network and checked againstgo.sumas usual. The person's caches andGOPATHare only read. - Temp: a fresh directory per launch, set as
TMPDIR,GOTMPDIRandCLAUDE_CODE_TMPDIR. The system's/tmpis not granted, except srt's own built-in paths. - The host's state, kept apart from its configuration:
- claude: a fresh
CLAUDE_CONFIG_DIRper launch. Under--barethe harness reads no OAuth credential and no keychain, and takes its key fromANTHROPIC_API_KEYor anapiKeyHelperpassed with--settings(headless), so the person's~/.claudeand~/.claude.jsonare never needed and stay read-only. That also covers the paths the vendor warns about: "a sandboxed session that can write them can persist hooks, permission rules, or MCP servers that run unsandboxed the next time you launch Claude Code". - opencode: fresh
XDG_DATA_HOMEandXDG_STATE_HOMEper launch, and anXDG_CACHE_HOMEper run, because opencode downloads and later runs programs from its data and cache folders. abcd seeds the run's cache once, at the run's start and outside the fence, from the person's: byclonefileon APFS or a reflink on Btrfs and XFS where the filesystem offers one, and by a plain copy elsewhere. Seeding per launch would copy the cache again for every role. The run's launches share the seed, so what one launch fetches there a later launch of the same run may run; that stays inside the run's fence, and nothing written there flows back to the person's cache. The person's configuration folder stays where it is, read-only, since it names the model and the server (opencode.go). The person's credential file is reached through a read-only link from the fresh data folder. A provider whose sign-in rewrites that file cannot refresh inside the fence: the launch fails, naming it, and the person uses a key for runner launches. abcd sees only the<provider>/<model>route, not a base URL set in opencode's configuration, so a provider abcd does not know, or a local model server, is reached by adding its host torunner.sandbox.hosts. Step 1 measures all of this.
- claude: a fresh
- abcd's home (
~/.abcd.noindex/) is readable and is not writable, except the launch's own worktree inside it. So the next launch's policy (config.json,rules.json,trusted-roots,load-limits, the other worktrees) is the person's alone to change.
Reads. Reads stay open, as srt's default leaves them, except for the credential stores a launch never needs. Those are denied:
~/.sshand~/.gnupg;~/.aws,~/.config/gh,~/.netrc,~/.git-credentialsand~/.docker/config.json;- abcd's own credential file under its home;
- on macOS, the login keychain's files,
~/Library/Keychains. Step 1's probes added this one: without it the keychain handed a stored GitHub token to a command inside the fence, although~/.config/ghwas denied (Step 1 evidence, probe 3).
The vendor's guide says why the two layers go together: "Without network isolation, a compromised agent could exfiltrate sensitive files like SSH keys". The model endpoint is reachable, so this narrows what a misled agent can send there. It is not part C's promise.
Writes srt already allows outside the list (the child's standard streams, /tmp/claude, ~/.npm/_logs, ~/.claude/debug) are named in the run record's fence block as the fence's own, so the list abcd states is the list that holds.
The policy goes into a settings file abcd writes for each launch, inside the run's fence directory but outside every path the launch may write. It is never ~/.srt-settings.json, and --control-fd is not used, so nothing can change the policy while the launch runs.
Commits inside the fence
The agent commits in its own worktree: its brief tells it to (internal/core/implement/loop/brief.go:583). Two things a commit sets off would otherwise fail inside the fence.
Automatic gc. After a commit git may run git gc --auto, through git maintenance run --auto, and that wants packed-refs and .git/gc.log, neither of which is on the write list. abcd's own git turns automatic maintenance off on its command line, but the agent's git is not abcd's, and the launch's environment drops every inherited GIT_CONFIG_* (ScrubbedEnv, internal/gitutil/net.go:37). So before each launch abcd writes gc.auto=0 and maintenance.auto=false into the lane's worktrees/<name>/config.worktree, outside the fence. That file is on the deny list, so the agent cannot take the values out. Git reads config.worktree only in a repository with extensions.worktreeConfig on, and abcd never turns that on, since doing so rewrites the main .git/config and changes how the repository works. So the launch also carries both values as its own seal (GIT_CONFIG_COUNT with its keys and values, set over the scrubbed environment, internal/core/runner/proc.go:168), which holds in every repository. A gc the agent forces anyway is refused after its commit has landed, and is recorded like any other refusal.
Commit signing. Where the person's git signs every commit (commit.gpgsign), the agent's commit needs the signing key, and the read list denies ~/.gnupg and ~/.ssh. On Linux srt's seccomp filter also blocks the Unix socket a signing agent answers on. abcd's own commits sign as the person's commits do (internal/core/implement/loop/pickcommit.go:19-21), and that does not change. The key is not opened to the agent: an agent that can read it can sign anything as the person. Fenced lanes commit unsigned (question d, dcn-2610222041439951), with commit.gpgsign=false in the launch's seal, and that abcd signs outside the fence, where it lands the work. Before its push, the land stage rewrites each lane commit with the same tree and message, in the same order, signed under the person's own configuration. Its judged-head check (landPrepare, internal/core/implement/loop/land.go:198) then compares the rewritten chain, tree for tree, with the head its validators judged.
A machine without a fence
Ruling 7: the run stops until the person acts. When a run is about to start and any role it may launch is routed to a runner, abcd checks the fence (Fence.Check) before creating the run. That covers a build start, each drain lane's start, and an intent interview start. The check:
- finds
srton PATH and admits it as a harness is admitted (launcher.admit): an absolute path, not writable by group or other, not inside the repository or the checkout. srt's own README warns that a copy inside a writable path can be rewritten from inside the sandbox. srt is a Node script, so the check also covers the interpreter its first line names, found on PATH, and the package folder the script resolves into, and none of them may sit inside a path any launch may write; - reads its version. abcd was tested against 0.0.79, and that is a statement of what was tested, not an exact pin: a 0.0.x version promises nothing between releases, so no range can be trusted, and an exact pin would break every machine at each upstream release without saying whether the fence still holds. A version other than the tested one is warned about once, at the run's start, naming the version found and the one tested, and is not refused. The tested version moves in a change that runs the
fencetest lane on the new one; - runs a trial launch,
srt --settings <trial> -- /bin/sh -c ': > "$1"; : > "$2"' sh <inside> <outside>, and checks from outside that the write inside landed and the write outside did not. The trial, not the version string, is what decides: it makes "fenced" a fact, not an assumption, perenforcement-claims-are-facts.
A failed admission or a failed trial refuses the run: exit 2, nothing created and nothing launched. The refusal names the reason (srt not found, refused, bubblewrap or user namespaces missing as srt reports, the trial failing to start, or the trial writing outside), how to fix it, and the off switch.
Where srt is not found, abcd offers to install it (question b, dcn-2610222041424027): it names the exact install it would run (srt's npm package at the tested version, and on Linux the system packages srt needs, through the system's package manager) and asks. It installs only after the person's explicit yes, given in the session or the terminal that started the run, never by an agent's answer and never by a flag or a config value. The install runs with everything the person's account can do, which the product thinker accepted. After it, the check runs again from the start, admission and trial included. Anything short of a yes installs nothing, and the run stays refused as above.
The off switch is runner.sandbox.off: true in ~/.abcd.noindex/config.json. The runner namespace is already the machine's alone, and a repository's .abcd/config.json that sets it is refused, naming the machine's file (runner.Load). No flag sets it either, so a project's files and a command an agent can type can never turn the fence off. With it set, the run says once, at its start, that its launches are not fenced and where that was set.
A wrap that fails during a run (srt removed, its settings refused) is a new runner reason, fenced. It is not a fallback: like a rate limit, it hands the role to no other route. The lane refuses at its runner stage with the same remedy.
The guard inside the fence
The approver runs inside the fence. The harness starts abcd guard approve itself, as the one MCP server on stdio its --mcp-config names (approverConfig on feat/headless-approvals), and a child of the wrapped process is inside the wrapper. Keeping it inside costs nothing and is the safer side:
- it only reads: abcd's own binary, the worktree's
.abcd/guard.jsonand the bundled registry, all readable in the fence. It writes nothing; - its answer can only narrow what the agent does. An allow it gives still runs inside the fence, so an approver that is wrong, or fooled by the input it judges, can never let a command past the boundary;
- putting it outside would mean a channel crossing the fence, carrying input the agent controls, to a process with the person's full reach. That channel would be a way out, and abcd would have to build it, which ruling 6 rules out. On Linux, srt's seccomp filter blocks exactly that kind of Unix-socket bridge.
The command the --mcp-config names is abcd's own executable, os.Executable() of the abcd that starts the run. It is never a name looked up again on PATH, and never go run ./cmd/abcd from the worktree, whose source the agent may edit. It is admitted by the same rule as srt: an absolute path, not writable by group or other, and never inside a path the launch may write. An executable that fails the rule refuses the launch, naming it.
So the guard keeps its place as adr-42's mistake filter, teaching inside the boundary the OS enforces. One limit is stated, not fixed: the approver judges against the registry of the working tree, which the agent can edit. A registry switched off is refused (registry-not-in-force). An entry the agent deletes stops teaching for the rest of that launch, and the fence still bounds what follows.
Checks that read the machine run outside
The load gate, the peers scan, and the reservation renewals and fences the loop takes between stages already run in abcd, outside any launch. What changes is what happens to a gate an agent reaches inside the fence: make preflight waits at abcd implement load --wait once, as one whole run, and inside the fence that waiter would read the fence's view of the process table. On Linux the process namespace shows it only its own processes. On macOS it sees none at all: /bin/ps cannot be started inside the fence and pgrep finds nothing, so implement load reports the check unavailable and carries on (Step 1 evidence, probe 5). It also could not write its hold under abcd's home, and on macOS today's waiter already refuses for that reason rather than opening.
So a waiter never judges the machine from inside a fence:
- a waiter that finds its launch's gate directory (an
ABCD_FENCE_GATEthe launch sets) asks through it, and waits for an answer; - a waiter that cannot write its hold refuses, naming the fence, rather than opening. That covers an agent that unsets the variable;
- for each request, the runner starts, outside the fence, an ordinary
abcd implement load --wait --gate <dir>process. It judges the whole machine as every waiter does, visible in the process table to every other account's waiters. When the gate opens it takes the hold, and writes its answer where the fenced side can read it and cannot write. - that outer waiter then holds the gate for the inner command's length, as
--wait -- <command>does, until the inner side writes its done marker or the launch ends; - one request may be outstanding per launch, and the outer waiter's own timeout bounds it.
A preflight run inside the fence therefore runs its commands inside the fence, under a gate read outside it. Its receipt lands in the worktree's local tier, which the agent may write. None of this needs the fence to exist: a waiter that can write its hold behaves as today, so step 12 can land before any other step.
How each criterion is met
- A write outside is refused and recorded. The derived list (step 3) is what the wrapped launch can write (step 7). The fence's refusals are read back from the launch's stderr and the tool errors in its transcript (
Fence.Refusals), and written to the lane's run record underfence.refusals, each with its path and the tool that tried (step 10). - Edit, commit and push succeed. The worktree, and the parts of the main git directory a lane's commit writes, are writable (step 3), and the commit's gc and signing are settled before the launch (step 7), so the edit and the commit land. The push is the land stage's, outside the fence, as it is today. Step 15's live run shows all three.
- abcd's settings, rules, trusted roots and other worktrees are refused. abcd's home is read-only except the launch's own worktree (step 3). Step 10's real-fence test writes each of them and asserts the refusal.
- No fence: the run is refused before its first launch. Steps 5 and 6.
- The off switch is the person's alone and said once. Steps 4 and 6.
- A fresh Go cache of its own. Step 13.
- The load gate reads the whole machine from outside. Step 12.
- The guard's refusal reaches the agent inside. Step 11.
- Host settings byte-identical. Step 14.
Proof
The tests that matter run against the real fence. A test lane under the build tag fence runs real srt on macOS and on a Linux runner with bubblewrap installed, wrapping a stand-in harness: a script named claude or opencode on a PATH the test controls, which tries the writes, commits and gates each criterion names. Fakes stand in only for what a test cannot cause, such as a missing srt or an untested version. Before this spec closes, one build of a real intent runs fenced on the built binary with the real claude runner (and the real opencode runner where routed), on macOS and on Linux, and its run record and the before-and-after hashes of the person's host settings are the evidence (step 15), per the product thinker's rule that real runs are the proof.
The fence lane is a Make target that joins make preflight beside smoke and evals-cold-reading (Makefile:297). On a machine where srt does not pass Check, preflight says by name that it skipped the lane: a skip, never a pass. CI runs the lane where it cannot skip: in .github/workflows/ci.yml, on a Linux runner with unprivileged user namespaces, where ubuntu-24.04 needs the AppArmor sysctl kernel.apparmor_restrict_unprivileged_userns=0 set first, with bubblewrap, socat, ripgrep, Node and srt installed; and in the macOS job where it runs. So every merge-queue entry runs the real fence on Linux.
Known limits, stated, not claimed
- On macOS srt's README gives "no guarantee" for a hard link, inside an allowed path, to a file outside it.
- On Linux srt's own deny scan is fixed at launch. abcd's explicit denies cover the main git directory, which exists before the launch, and not a repository the agent creates later inside its worktree.
- Go programs that check TLS through the system's trust service fail under Seatbelt (the vendor's guide names
gh,gcloudandterraform), andgoitself is one of them. Measured on macOS (Step 1 evidence, probe 1): every HTTPS requestgomakes inside the fence fails withx509: OSStatus -26276, both a module download fromproxy.golang.organd the checksum lookup it routes through that proxy, so a module missing from the person's cache, or fromgo.sum, cannot be fetched or verified. A build whose modules are all ingo.sumand in the person's cache works, through the file proxy, with no network. WithSSL_CERT_FILEnaming the system's certificate file (/etc/ssl/cert.pem), Go reads its roots from that file instead of the trust service, and the download and the checksum verification both pass, with nothing in the fence widened. srt's own remedy,enableWeakerNetworkIsolation, opens the trust service and is what its README warns is an exfiltration path; it is not used. So on macOS the launch also setsSSL_CERT_FILE(step 7). A program that ignores that variable and asks the trust service still fails, and is named where it does. - A private module (
GOPRIVATE) is fetched from its version-control host with the person's own credentials. One held in a store the read list denies (~/.ssh,~/.netrc,~/.git-credentials,~/.config/gh) cannot be read, and the fetch fails naming the module. Measured on macOS (probe 3): git's keychain helper gave nothing inside the fence (failed to get: -128, as it does outside in a session nobody can answer a prompt in), and the GitHub helpergh auth git-credentialcould not start, so no git credential reached the fence. The keychain itself did answer: a token stored there was returned in full until~/Library/Keychainsjoined the read denies (Reads, above). The ssh agent's socket is refused inside the fence (Error connecting to agent: Operation not permitted), so neither a fetch over ssh nor an ssh signature can use the agent. - srt's CLI reports no refusal of its own on macOS, with or without
--debug: the refusal reaches the launch's stderr only as the refused program's own error,Operation not permittedwith the path (probe 4). The kernel logs each refusal, tagged by srt (Sandbox: <program>(<pid>) deny(1) file-write-create <path>, thenCMD64_<command in base64>_END_<session>_SBX), to the unified log, which alog streamrunning during the launch sees and a laterlog showdoes not. That stream also carries refusals nothing asked about (sysctl-read kern.iossupportversionfrom every shell).Fence.Refusalsreads the program errors (step 10); reading the log stream as a second source is left to step 10 to weigh. - Every commit inside the fence, with git 2.52, prints
error: Unable to create '<git dir>/packed-refs.lock': Operation not permittedduring its ref update, and still exits 0 with its commit, ref and reflog written (probe 2).packed-refsstays off the write list.Fence.Refusalsrecords the line like any other refusal, and the lane does not read it as a failed commit. - On Linux, srt covers a protected path that does not yet exist with a placeholder on the host, and a SIGKILL skips its cleanup. abcd's timeout kill is the group's SIGKILL (
procgroup.Kill), so a timeout first sends SIGTERM to srt and kills the group only after a grace period (step 8). After every launch abcd also removes, outside the fence, any empty placeholder that appeared at one of srt's protected names and that git does not track, so none is committed or left in the person's worktree (step 9). Step 1 checks whether bubblewrap's own session takes descendants out of the group abcd kills. - srt quotes the argument vector into a shell string on macOS and Linux, which amends
proc.go's rule that the argv is never a shell line. abcd's wrapped vector is fixed words, a role name from a closed list, and paths abcd made. A path carrying a quote or a control character is refused before the launch, and a test sends a prompt through with every shell metacharacter (step 7). - A fenced agent can still read most of what the person can read. The intent's press release was narrowed to what holds at step 2 (question e, dcn-2610222041434056): the agent cannot change anything outside its own work, and cannot read the person's passwords, keys and sign-ins (the read denies above, the macOS keychain included).
Coordination
- Steps 3 to 10, 13 and 14 change
internal/core/runnerand the newinternal/core/fence, so they land in order. Step 8 also changesinternal/procgroup, which every launch and every waiter kills through, so it reserves that package too. - Step 11 waits for
feat/headless-approvalsto merge, and reservesinternal/core/runner/claude.goafter it. - Step 12 changes
internal/core/implement/load_wait.go, which every preflight runs through, so it lands alone, after a reservation. It does not depend on the fence, and can land before step 3. - Step 5 changes
Makefileand.github/workflows/ci.yml, which every preflight and every CI run read, so it reserves both. - Every step reserves its footprint with
abcd reservebefore it starts.
Out of scope
- Part B: the isolation report for drains and builds a person's own session drives. Part C: the network promise and its claim (the host list here is functional, not a promise).
- Confining reads. Secrets the account can read stay readable to a fenced agent.
- Windows, cloud sessions, and more than one project per run (the intent's scope conditions).
- Caches of other toolchains (npm, cargo, pip): writes to them are refused, and a run that needs one shows up in step 15's evidence before anything is added for it.
Questions for the product thinker
Put at step 2, after step 1's probes. Each question is written for the product thinker, in plain words, and each answer says what it leads to. The mechanism behind each one is in the sections above.
Answered by the product thinker on 2026-10-22, each recorded as a decision and folded into the sections above: (a) require srt (dcn-2610222041417934); (b) offer to install it, only on an explicit yes (dcn-2610222041424027); (c) the derived list, not announced per run (dcn-2610222041427241); (d) abcd signs at hand-in (dcn-2610222041439951); (e) narrow the words to what holds (dcn-2610222041434056). The questions as they were put stay below for the record.
- (a) One extra program to install. Fencing the agents abcd starts needs one program abcd does not ship: Anthropic's sandbox. The person installs it once, with the small runtime it needs. Everything else in abcd keeps needing nothing extra.
- Require it for the agents abcd starts. Leads to: on a machine without it, abcd will not start such an agent until the person installs it or switches the fence off for that machine.
- Use another open-source fence instead, a single file with nothing else to install. Leads to: less to install, but the fence around Claude is not Anthropic's.
- Wait for a fence that needs nothing extra. Leads to: the agents abcd starts keep running unfenced until then.
- (b) When the program is missing.
- abcd offers to install it, and installs only after the person says yes. Leads to: one step for the person; the install runs with everything the person's account can do.
- abcd says how to install it and how to switch the fence off, and installs nothing. Leads to: the person installs it by hand.
- (c) Which internet addresses a fenced agent may reach. The fence lets an agent reach only the addresses it is given.
- The AI service, what the project's build needs to download, and any the person adds. Leads to: builds that download code keep working, and anything more is the person's to add.
- The AI service and any the person adds, nothing else. Leads to: a build that needs a download fails until the person names where it downloads from.
- The first, with each run listing every address it allows when it starts. Leads to: the same reach, said out loud at every run.
- (d) Signed commits. Where the person signs every commit, an agent inside the fence cannot reach the signing key.
- The agent's commits are unsigned inside the fence, and abcd signs them when it hands the work in. Leads to: the work arrives signed, and the agent never holds the key.
- The agent may use the signing key. Leads to: commits are signed as today, and a misled agent could sign anything in the person's name.
- abcd refuses a fenced run while signing is on. Leads to: the person turns signing off for these runs, or turns the fence off.
- (e) The promise that the person's other projects and settings "stay out of its reach". The probes show the agent cannot change anything outside its own work. It can still read most of what the person can read; only the places that hold passwords, keys and sign-ins are hidden from it.
- Keep the words, and hide more from reading. Leads to: more work now, and some tools may stop working inside the fence.
- Narrow the words to what holds: it cannot change anything outside its own work, and cannot read the person's passwords, keys and sign-ins. Leads to: a smaller promise that is true as written.
- Keep the words, and state the reading limit in the guide. Leads to: the promise reads wider than what it does.
Footprint
- packages: internal/core/fence, internal/core/fence/srt, internal/core/runner, internal/core/implement, internal/core/implement/loop, internal/core/interview, internal/procgroup, internal/surface/cli, commands, docs, ACKNOWLEDGEMENTS.md, Makefile, .github/workflows/ci.yml, .abcd/development/brief, .abcd/development/research/notes, .abcd/development/decisions/adrs
- tests: internal/core/fence (policy derivation, the seam's test double, admission and trial tables), internal/core/fence/srt (settings file, argv and refusal parsing), internal/core/runner (fenced argv, environment and seal, the fenced reason, config tests), internal/procgroup (the kill order), internal/core/implement (the fenced waiter and its outer gate), internal/core/implement/loop and internal/core/interview (refusal before a run starts, the ref diff and placeholder sweep, the fence block of the run record), internal/surface/cli (build, drain, interview and status surface tests), the
fence-tagged real-srt lane on macOS and Linux in preflight and CI, and step 15's live fenced build
Steps
- Probe srt on the real harnesses before anything is signed off or built
- packages: .abcd/development/research/notes, .abcd/development/intents
- tests: an abcd lab on macOS and on Linux with bubblewrap, each probe named in the research note:
claude --print --bareandopencode runwrapped by srt 0.0.79 with the step 3 policy written by hand;CLAUDE_CONFIG_DIRper launch with the person's~/.claudeand~/.claude.jsonread-only; opencode with fresh XDG folders, a per-run cache seeded by clone, a read-only credential link, and a refreshing sign-in;go buildandgo test ./...of abcd itself with the per-runGOPATHand caches, a module missing fromgo.sumverified through the checksum database, aGOPRIVATEmodule fetched from its host, and whether module downloads pass TLS under Seatbelt; whether a keychain credential helper answers git inside the fence; a commit in a linked worktree under the narrowed main.git, with and withoutgc.auto=0, withpacked-refsunwritable, and withcommit.gpgsignon; a hard link from the worktree to a file outside it on macOS; SIGTERM cleanup, and whether bubblewrap's session leaves the process group abcd kills; what opencode fetches at start with a seeded cache; a prompt with shell metacharacters. Probing builds nothing, and its findings are what step 2's questions read. Each limit found becomes a scope condition on the intent or a capture of its own, and a failure on the macOS or Linux half goes back to the person at step 2, with nono as the named alternative - evidence: the macOS half's first probes ran on 2026-10-17 and are recorded under Step 1 evidence, after the steps. The Linux half, the agent sessions and the probes listed there as not yet run are still to do
- Sign-off: the person answers the five questions for the product thinker (done 2026-10-22; see the answers under Questions for the product thinker)
- packages: .abcd/development/brief, .abcd/development/decisions/adrs, .abcd/development/specs
- tests: each answer recorded as a decision and folded into this spec; on a yes to question (a), the proposed marks come off adr-22's consequence and the dependencies chapter's paragraph (02-constraints/02-dependencies.md), and on a no both are withdrawn; record-lint and docs-lint pass over both and over the ADR's acceptance
- Declare the fence seam and derive a launch's fence-neutral policy (itd-2610161621364075 criteria 1, 2, 3)
- packages: internal/core/fence
- tests: fence:
Policynames no mechanism, andFenceis met by a test double the runner's tests use; for a lane launch, for an interview launch, and for each runner, the write list is exactly the worktree (or the record's folder), the receipt folder, the main.git'sobjects/, the lane's ownworktrees/<name>/without itsconfig.worktree,gitdirandcommondir, andrefs/heads/build/with its reflogs, the run'sGOPATHand Go caches, the launch's temp and host-state folders and the run's opencode cache;hooks/,configandpacked-refsare never granted; abcd's home is read-only but for the worktree; the credential stores are denied for reads,~/Library/Keychainsamong them on macOS; a path given through a symlink is refused; nothing read from the repository's.abcd/config.jsonchanges the list; the host list is the runner's endpoint, with ago.modtheGOPROXY/GOSUMDBhosts and theGOPRIVATE/GONOPROXYhosts, and the machine layer's additions, and a pattern too broad to pass is named
- Read the off switch, the backend and the host additions from the machine layer only (criterion 5)
- packages: internal/core/runner, .abcd/development/brief
- tests: runner:
runner.sandbox.off,runner.sandbox.backendandrunner.sandbox.hostsread from the machine file; the backend defaults tosrtand an unknown one is refused, naming the known ones; the same keys in a repository's.abcd/config.jsonrefuse the load, naming the machine's file; a host entry that is*, a bare top-level domain or nothost[:port]is refused; no flag sets any of them
- Admit srt and prove the fence runs (criterion 4)
- packages: internal/core/fence, internal/core/fence/srt, internal/core/runner, Makefile, .github/workflows/ci.yml
- tests: srt's
Check: srt absent, relative, group- or other-writable, inside the repository, or an interpreter or package folder that fails the same checks is refused with its reason and the off switch; a version other than the tested 0.0.79 is admitted with one warning naming both; the evidence carries the admitted paths, the version and the trial's result;fence-tagged: the trial launch's inside write lands and the outside write does not, on macOS and Linux; a trial whose outside write lands, or that fails to start, refuses;make fenceruns the tagged lane and preflight names it as skipped where srt failsCheck; CI's Linux job runs it on a runner with user namespaces, setting the AppArmor sysctl on ubuntu-24.04, and fails rather than skips
- Refuse a run before its first launch, and say once when the fence is off (criteria 4, 5)
- packages: internal/core/implement/loop, internal/core/interview, internal/surface/cli, commands
- tests: loop and cli: a
build, adrainlane start and anintent interviewwith a role routed to a runner, on a machine whose fence check fails, exit 2 before the run is created and start no process; a run whose roles all stay on the host checks nothing; with the switch on, the run's output says once that its launches are not fenced and names the machine file; an untested srt version is said once at the run's start
- Wrap every launch at the launch point, with its environment (criteria 1, 2)
- packages: internal/core/runner, internal/core/fence, internal/core/fence/srt, ACKNOWLEDGEMENTS.md
- tests: runner: a fenced claude and opencode launch run
srt --settings <file> -- <harness> …with the harness's own argv unchanged after--; the environment sets the per-launch temp, the run'sGOPATHand Go caches and the host-state folders over the parent's, on macOS setsSSL_CERT_FILEto the system's certificate file, and sealsgc.auto=0andmaintenance.auto=false, andcommit.gpgsign=falseunless question (d) is answered otherwise; the lane'sconfig.worktreetakes the gc values when the repository hasextensions.worktreeConfigon, and the main.git/configis unchanged either way; opencode's cache is seeded once per run, by clone where the filesystem offers one; the settings file sits outside every writable path and round-trips through srt's schema; a wrap failure is reasonfenced, records no fallback and runs no other route;Wrap's cleanup runs on every way a launch ends; loop: a lane whose launch is refused as fenced refuses at the runner stage naming the remedy; a path carrying a quote or a control character is refused before the launch;fence-tagged: a prompt carrying every shell metacharacter arrives byte for byte; a commit in the lane leaves no gc refusal; the run's fence folder is removed when the run finishes
- Kill a fenced launch in order (criteria 1, 2)
- packages: internal/procgroup, internal/core/runner
- tests: procgroup: a timeout sends SIGTERM to srt first and kills the group only after the grace; an srt that exits on SIGTERM within the grace is not sent SIGKILL; the kill still goes through the pid the handle holds, never a pattern;
fence-tagged: after a timeout no descendant of the launch survives, including any bubblewrap session step 1 found outside the group
- Check the refs and sweep srt's placeholders after each launch (criteria 1, 2)
- packages: internal/core/runner, internal/core/fence, internal/core/implement/loop
- tests: loop: a launch that moved a ref other than its lane's branch is recorded and its lane refuses to land; a launch that moved only its own branch lands; an empty untracked placeholder at one of srt's protected names is removed after the launch, and a tracked or non-empty file there is left alone; both run after a killed launch as after a clean one
- Record the fence's refusals in the run record (criteria 1, 3)
- packages: internal/core/fence/srt, internal/core/runner, internal/core/implement/loop, internal/surface/cli
- tests: srt:
Refusalsreads each refusal out of srt's stderr and the transcript's tool errors, with its path and tool;fence-tagged: a stand-in harness that writes outside its worktree, to~/.abcd.noindex/config.json,rules.json,trusted-rootsand another lane's worktree is refused by the OS each time, and the lane's run record lists each refusal with its path;implement statusrenders the fence block (mechanism, version, whether it was the tested one, policy digest, refusals) and its--jsoncarries it
- Keep the guard teaching inside the fence (criterion 8; waits for
feat/headless-approvals)- packages: internal/core/runner, internal/surface/cli
- tests: runner: the command
--mcp-confignames isos.Executable(), admitted by srt's rule, and a launch whose executable sits inside a path the launch may write, or isgo run ./cmd/abcdfrom the worktree, is refused naming it;fence-tagged: a stand-in harness speaking the prompt tool's MCP toabcd guard approvestarted inside srt gets a refused command answereddenywith the guard's reason and advice, and a cleared one answeredallow; the approver's run leaves no fence refusal of its own; a realclaude --print --barelaunch honours--mcp-configunder--strict-mcp-config, starting the approver and no other server
- Read the load gate outside the fence (criterion 7; does not depend on the fence and can land first)
- packages: internal/core/implement, internal/core/runner, internal/surface/cli, commands
- tests: implement: a waiter that cannot write its hold refuses naming the fence and never opens; a waiter with a gate directory opens only on the outer waiter's answer; an outer waiter given a loaded snapshot keeps the gate shut while the fenced side's own view is idle; it holds while the inner command runs and releases on the done marker or the launch's end; a second outstanding request from one launch is refused; a waiter that can write its hold behaves as today; cli:
implement load --wait --gateis in help and the generated reference
- Give each run a fresh Go cache (criterion 6)
- packages: internal/core/fence, internal/core/runner, internal/core/implement/loop
- tests: fence:
GOPATH,GOCACHEandGOMODCACHEpoint into the run's fence folder andGOPROXYleads with the person's module cache as a file proxy;fence-tagged: ago buildin a fenced launch fills the run's caches; a build that verifies a module missing fromgo.sumwritespkg/sumdbunder the run'sGOPATH; ago installlands in the run'sGOPATH/bin; the person'sGOPATH,GOCACHEandGOMODCACHElistings and modification times are unchanged
- Leave the person's host settings untouched (criterion 9)
- packages: internal/core/fence, internal/core/runner
- tests:
fence-tagged: a stand-in claude that writes~/.claude/settings.json,~/.claude.jsonand~/.claude/agents/, and a stand-in opencode that writes its configuration folder, are refused; the hashes of every host configuration path the policy names are identical before and after a fenced launch
- State the rule and prove it on a real run
- packages: .abcd/development/brief, commands, docs
- tests: a brief invariant says an agent abcd launches runs inside a fence the operating system enforces, unless the person turned it off in their user layer, and the adapters chapter describes the fence seam;
commands/build.mdandcommands/intent.mdname the refusal and the off switch; a how-to underdocs/says how to install the fence and turn it off, crediting the package under the docs-lint allow escape; docs-lint and record-lint pass; a fencedbuildof a real intent on the built binary, on macOS and on Linux, records a refused outside write, commits in its worktree, lands its push, and leaves the person's host settings byte-identical
Step 1 evidence
macOS, 2026-10-17
Run on macOS 27.0 (arm64) with srt 0.0.79 from npm, Node 25.1.0, go 1.27.1, git 2.52.0, Claude Code 2.1.302 and opencode 1.18.31. Each probe ran srt --settings <run>/settings.json -- <command> from a scratch lane worktree of abcd, ~/.abcd.noindex/worktrees/<root-sha>/probe-lane on a branch build/probe-srt-lane cut from origin/main, whose git directory is the main checkout's (<checkout>/.git). <run> is a fresh folder under $TMPDIR. The worktree, both branches it made, its nine loose objects and its receipt folder were removed afterwards.
Nothing in the settings was widened to make a probe pass. One variant was tighter: probe 3 ran again with ~/Library/Keychains added to denyRead.
The settings file. srt was given real absolute paths; they are written here with ~, <checkout> and <run>.
{
"network": {
"allowedDomains": ["api.anthropic.com", "proxy.golang.org", "sum.golang.org"],
"deniedDomains": []
},
"filesystem": {
"denyRead": [
"~/.ssh", "~/.gnupg", "~/.aws", "~/.config/gh", "~/.netrc",
"~/.git-credentials", "~/.docker/config.json", "~/.abcd.noindex/credentials.json"
],
"allowRead": [],
"allowWrite": [
"~/.abcd.noindex/worktrees/<root-sha>/probe-lane",
"<checkout>/.abcd/.work.local/run/<run-id>/lane-1",
"<checkout>/.git/objects",
"<checkout>/.git/worktrees/probe-lane",
"<checkout>/.git/refs/heads/build",
"<checkout>/.git/logs/refs/heads/build",
"<run>/gopath", "<run>/gocache", "<run>/gomodcache", "<run>/tmp",
"<run>/claude-config", "<run>/xdg-data", "<run>/xdg-state", "<run>/xdg-cache"
],
"denyWrite": [
"<checkout>/.git/worktrees/probe-lane/config.worktree",
"<checkout>/.git/worktrees/probe-lane/gitdir",
"<checkout>/.git/worktrees/probe-lane/commondir"
]
}
}
The environment. Each launch started from an empty environment (env -i), as the runner's scrubbed one is, with HOME, USER, PATH and LANG; TMPDIR, GOTMPDIR and CLAUDE_CODE_TMPDIR set to <run>/tmp; GOPATH, GOCACHE and GOMODCACHE set to the run's folders; GOPROXY=file://~/go/pkg/mod/cache/download,https://proxy.golang.org,direct; CLAUDE_CONFIG_DIR and the three XDG_* folders set to the run's; GIT_TERMINAL_PROMPT=0; and the seal as GIT_CONFIG_COUNT=3 with gc.auto=0, maintenance.auto=false and commit.gpgsign=false. GOFLAGS=-p=4 kept the cold build's share of a machine running three other preflights small; it is not part of the policy.
Trial launch. The spec's trial (step 5): the write inside <run>/tmp landed, and the write outside was refused, Operation not permitted, and was not on disk afterwards. sh exits 1 at the refused redirection, so the trial is judged from outside, by the two files, as step 5 says, and not by its exit status.
| Probe | What ran inside the fence | Outcome |
|---|---|---|
| 1. Build and test | go build ./... with every cache empty |
Passed in 16 s. 75 modules came from the person's cache through the file proxy, with no network. |
go test ./internal/fsutil/ |
Passed. | |
go get rsc.io/quote/v4@v4.0.1 in a scratch module, a module not in the person's cache |
Failed: tls: failed to verify certificate: x509: OSStatus -26276. Go asks the system's trust service, which the fence closes. |
|
go get github.com/google/go-cmp@v0.7.0, in the person's cache but not in the scratch go.sum |
Failed at verification: Go asked the checksum database through https://proxy.golang.org/sumdb/sum.golang.org/supported, with the same TLS error. |
|
both again with SSL_CERT_FILE=/etc/ssl/cert.pem |
Passed. The checksum database's cache was written to <run>/gopath/pkg/sumdb/sum.golang.org/, under the run's GOPATH as the spec expects. Whether sum.golang.org is dialled directly, or only through the proxy, was not measured. |
|
| 2. Commit | git add and git commit in the lane, through the repository's own hooks (core.hooksPath names the main checkout's .githooks) |
Committed, exit 0. pre-commit, prepare-commit-msg and commit-msg all ran inside the fence, and commit-msg built abcd with the run's caches. The commit's objects, the lane branch and its reflog landed in <checkout>/.git. The seal read back as gc.auto=0, maintenance.auto=false, commit.gpgsign=false. Each commit also printed error: Unable to create '<checkout>/.git/packed-refs.lock': Operation not permitted, from git's own ref update, without failing. |
writes to .git/hooks/, .git/config, .git/packed-refs, .git/HEAD, the lane's config.worktree (create), gitdir and commondir, and the main checkout's .githooks/pre-commit |
All refused, Operation not permitted. Their checksums matched before and after. |
|
git config --local srtprobe.x 1 |
Refused: could not lock config file <checkout>/.git/config: Operation not permitted. |
|
git update-ref refs/heads/srt-probe-outside HEAD |
Refused: the ref's lock file could not be created. | |
git update-ref refs/heads/build/srt-probe-other HEAD |
Allowed, as the spec expects: the folder grant covers every lane's branch, and only the ref check after the launch (step 9) catches it. | |
write to the lane's receipt folder; open the run's state.json for writing |
Receipt written; state.json refused. |
|
| 3. Credentials | git credential-osxkeychain get for github.com |
failed to get: -128, the same as outside in this session: the item needs a prompt nobody can answer. Nothing returned. |
git credential fill for github.com (helper: gh auth git-credential) |
Nothing returned: gh could not start, open ~/.config/gh/config.yml: operation not permitted. |
|
gh auth status |
Same refusal; gh does not start. |
|
security find-generic-password -s gh:github.com -w, counting the bytes only |
The keychain answered: 75 bytes, the same as outside. The GitHub token gh keeps in the login keychain was readable inside the fence. | |
the same, with ~/Library/Keychains added to denyRead |
Not found (exit 44), 0 bytes. | |
ssh-add -l with the person's SSH_AUTH_SOCK |
Refused: Error connecting to agent: Operation not permitted. |
|
| 4. Outside writes | create ~/probe-outside, ~/.ssh/x, ~/.abcd.noindex/trusted-roots.probe, a file in another worktree, ~/.claude/agents/srt-probe.md; open ~/.abcd.noindex/config.json and ~/.claude.json for writing; touch; mkdir |
All refused, and none was on disk afterwards; config.json and ~/.claude.json matched their checksums before and after. |
reads of ~/.ssh/known_hosts, ~/.config/gh, ~/.docker/config.json; and of ~/.claude.json, which is not denied |
The three denied reads were refused; ~/.claude.json was read. |
|
| how srt reports a refusal | srt adds nothing to stderr, with or without --debug. The only report is the program's own: <program>: <path>: Operation not permitted. The kernel logs Sandbox: <program>(<pid>) deny(1) file-write-create <path> with srt's tag CMD64_<command in base64>_END_<session>_SBX to the unified log: a log stream running during the launch sees it, and a later log show does not. |
|
| 5. Process table | ps -A, ps -p 1, pgrep -f 'make preflight', sysctl -n vm.loadavg |
/bin/ps cannot be started (Operation not permitted, exit 126); pgrep found 0 of the 9 preflight processes visible outside; the sysctl command was refused. Outside, ps -A counted 1444 processes. |
go run ./cmd/abcd implement load --site preflight |
LOAD CHECK UNAVAILABLE (preflight): could not read the process table (/bin/ps: fork/exec /bin/ps: operation not permitted); carrying on without checking for stray programs. The load average was still read (13.9 on 16 cores). |
|
go run ./cmd/abcd implement load --wait --timeout 20s -- /usr/bin/true |
Refused, exit 1: the hold could not be judged or written: openat .load-wait.lock: operation not permitted. Today's waiter does not open inside the fence. |
|
| 6. Harnesses | claude --version and opencode --version, with fresh CLAUDE_CONFIG_DIR and XDG_* folders; then again with the keychain denied |
Both started and printed their version under both settings, with nothing refused. opencode made opencode/repos and opencode/log in the data folder, opencode in the state folder and opencode/bin in the cache folder. No agent session ran and no model was used. |
What this changes in the spec.
- The read denies gain
~/Library/Keychainson macOS (Reads; step 3's tests). - On macOS the launch sets
SSL_CERT_FILEto the system's certificate file, sogocan fetch and verify modules without srt's weaker trust-service option (Known limits; step 7's tests). - The Known limits now state how srt reports a refusal on macOS, the
packed-refs.lockline each commit prints, and what the keychain and the ssh agent answered. - The checks that read the machine: on macOS a fenced command sees no processes at all rather than only its own, and today's waiter already refuses rather than opening. The conclusion, that the gate is read outside, is unchanged.
Not yet probed. The Linux half, all of it. claude --print --bare and opencode run as real sessions, with the person's ~/.claude and ~/.claude.json read-only, and opencode's seeded cache, read-only credential link and refreshing sign-in. go test ./... over the whole module (only ./internal/fsutil/ ran). A GOPRIVATE module. A commit without the gc seal, and one with commit.gpgsign on (the person's git does not sign). The hard link from the worktree to a file outside it. SIGTERM cleanup. A prompt carrying shell metacharacters.
Frontmatter
| id | spc-2610161751056096 |
|---|---|
| type | spec |
| lifecycle | open |
.abcd/development/specs/open/spc-2610161751056096-a-person-who-leaves-abcd-to-run-on-its.md
open on GitHub ↗ · commit history ↗
References1
- itd-2610161621364075The agents abcd launches on its own work inside a sandbox the operating system enforcesimplements
mentions22
- adr-22Bundled dependencies become pluggable adapters over a native default
- adr-42The guard's parse layer is a mistake filter, not a security boundary — matching is two-tier, and completeness is abandoned as a goal
- adr-2610161621415708Unattended runs abcd launches execute inside an OS sandbox
- dcn-26101618092596232026-10-16 — The fence for the agents abcd's runner launches (itd-2610161621364075 part A) is Anthropic's sandbox-runtime (srt, Apache-2.0, npm @anthropic-ai/sandbox-runtime, tested against 0.0.79, which is not an exact pin: an untested version is warned about and a failed trial launch refuses) for both the claude and the opencode runner, on macOS and Linux, run by abcd at its one launch point as srt --settings <per-launch file> -- <harness>, behind a fence seam (internal/core/fence: a fence-neutral Policy and a Fence interface with Check, Wrap and Refusals) whose one adapter, internal/core/fence/srt, runner.sandbox.backend selects with srt as its default. Grounds: ruling 6 names Anthropic's own fence while abcd launches Claude and rules out a fence of abcd's own; srt wraps any process, so one fence and one policy derivation serve both runners (one-canonical-primitive), which settles how opencode launches are fenced; it is the code Claude Code's own sandbox is built on and the vendor's guide names it for a whole-process boundary. Weighed and not picked: nono (Apache-2.0, Seatbelt and Landlock, pre-1.0, not Anthropic's; the first alternative if the srt probes fail), landrun and go-landlock (Linux only, and abcd would write the policy layer), bubblewrap alone (a building block, Linux only), nsjail (Linux only, built for jailing services), firejail (setuid root, CVE-2022-31214), a sandbox-exec profile of abcd's own (a fence of abcd's own, and the command is deprecated), Codex's sandbox (ships inside another vendor's agent; reconsidered when abcd launches Codex). An adversarial fit-challenge kept the pick and changed the design: the main git directory is narrowed to what a lane's commit writes with a ref check after each launch, srt's interpreter and package are admitted as well as its script, srt's placeholders are cleared after a killed launch, opencode's state is seeded rather than shared, and reads of credential stores a launch never needs are denied. srt is a new required program on the runner route and brings a network host list with it, since it refuses '*'; both wait for the product thinker's sign-off before anything is built. An advisor's fit review the same day kept the pick and changed the design again: GOPATH moves into the run's fence folder with the Go caches, since the checksum-database cache lives under it, and the hosts GOPRIVATE and GONOPROXY name join the host list; lane commits are proposed unsigned inside the fence and signed by abcd at land, so no signing key is opened to the agent; automatic gc is sealed off for each launch; the approver is abcd's own executable, admitted as srt is; srt is framed as adr-22's first declared exception, scoped to the runner route, with the per-machine off switch in place of degradation; and the probes now come before the sign-off. Five questions go to the product thinker with the sign-off, listed in spc-2610161751056096.
- dcn-26101619301916322026-10-16 — Refines dcn-2610161821420088: the headless approver runs each approved command from the working tree it judged it for. The host keeps the shell's directory from one call to the next, so an allow hands the command back as cd -- '<dir>' && <command>, the directory quoted as one literal word by internal/shellquote; a change of directory inside the line still holds for the rest of it. A relative removal target after a cd the line spells out and joins with && is read in that directory, and one after a change of directory the line leaves unknown is refused as a name the line does not place, with that reason. A removal (rm, rmdir, unlink, mv's sources, a find that deletes, an rsync --delete) whose fixed target lies outside the launched working tree, the run's own directories (the brief's and the receipt's, which the runner passes as --run-dir) and the temporary directory is refused. A write outside the working tree is judged as before, by the protected set alone: the OS sandbox of spc-2610161751056096 is what bounds those writes, not the approver. Three narrowings keep ordinary work from being refused: a git -c core.pager or core.sshCommand setting whose value is a fixed command is read as that command, as a bang alias already is, and approved when that command is clean, where git runs from the working tree and no earlier part of the line writes or removes a file; a glob removal whose fixed directory is inside the working tree and unprotected is approved unless a protected name could match the glob: one its literal text aims at (a name starting with a dot only by a pattern that spells the dot, as the shell does by default), an entry already standing in that directory that is or holds a protected path, or a protected path below it; and the words after a script's or a module's name belong to it, not to the interpreter, so node app.js -p 3000 and python3 -m pytest -c pytest.ini are approved. <!-- record-lint: forward-looking -->
- dcn-26101620400341172026-10-16 — Refines dcn-2610161930191632: an approved command comes back as cd -- '<dir>' && {, a newline, the command, a newline and }, so nothing of it runs when the change of directory fails. The approver's list of agent launches (the program names, the flags that turn a harness's own checks off, the wrappers and package runners it reads through, the packages that ship a harness and the variables a harness reads its settings from) is a mistake filter in adr-42's sense: it steers an agent that starts another harness by an ordinary route. A harness started by a route outside that list, such as another program name, a copied binary or a configuration file it reads, is bounded by the OS sandbox for launched agents (spc-2610161751056096), not by the list. So a further form is added to the list when it is common, not to close the class. <!-- record-lint: forward-looking -->
- dcn-26101711123847122026-10-17 — The headless approver closes the arithmetic routes a review found by reading the text as bash will read it where that is cheap: it joins a backslash-newline before scanning, refuses an alias or alias expansion turned on, a $'…' string that writes a byte by its code, and a builtin whose operands bash may read as arithmetic spelled with a quote or a backslash in its name; it reads a substring offset on any parameter and an array index only in an assignment, an expansion or a name a builtin is handed, so a regular expression's character class is no longer refused. It stops there by decision, not by oversight: on the default branch a launched role is granted the shell with no approver at all, so every route the approver misses is one the run has today; reading a shell line well enough to refuse every hidden command is not finite; and the approver stays a mistake filter (adr-42), with the boundary for launched agents the OS sandbox spec (spc-2610161751056096). Grounds: the default branch grants Bash in the implementer and reviewer roles (internal/core/implement/loop/drive.go). Refines dcn-2610170944153720.
- dcn-26101810034564442026-10-18 — Only the person sets the away mode, its push rights are narrow, and the guard's refusal is a mistake filter (facilitator-on-delegation, itd-2610180912189125 review round 2, choice (a); refines dcn-2610180913399042, replacing its clause that an agent sets the mode on the person's word): the prompt hook cannot tell the person's message from one another session relays, and its input names no origin, so a relayed word could let an agent grant itself, and every copy, push rights. So the guard refuses every route to setting the mode that it reads: `abcd mode autonomous` by the binary on PATH, the plugin root's binary or a built `abcd-<goos>-<arch>`; a `go run` of a package ending in `cmd/abcd`; any of them inside `sh -c` or in a script file a shell runs (adr-2610091150447054); a shell write into the away store under `~/.abcd.noindex/away/`; and an Edit or Write tool call on a path inside it. The person sets the mode with their own `!` command, which the host runs directly without going through Claude and so without the pre-tool hook, or at a terminal. A refused attempt is recorded in the store and shown to the person on the next prompt. What the mode grants is the session committing and pushing its own branches and opening their pull requests; never the default branch, a tag, a release cut, another person's pull request, a branch this copy only checked out to review, or a force push; merging follows the repository's own rule. This is chosen over the fallback, and is enough for now, because the grant only governs an agent that follows refusals: one that ignores them can push without any mode, so no shape of the mode adds a boundary inside the person's own session; the guard stops a refusal-following agent from granting itself on a relayed word; the rights are narrow, and every push is reversible and visible on the forge; and the record (who set it and from which copy) and the recorded refusals leave a trail the person reads on return. The guard stays a mistake filter (adr-42, and the decision of 2026-10-17 on the default branch that keeps the headless approver one, naming the OS fence as the boundary). That fence (spc-2610161751056096) is the boundary only for agents abcd's runner launches, whose writes to abcd's home it confines to their own worktree; it does not fence the person's own interactive session, where the stall happened, so there the narrow grant and the trail are the mitigation. The fallback (the mode grants no push rights, and the person's own standing instruction stays the authority to push) applies instead if the live capture shows that a message relayed from another session can run as a `!` command, or that an agent's tool call can reach the host's shell mode (grounds: plan review round 2, choice (a), and the coordinator's two addenda; the host's interactive-mode page, which says shell mode runs commands directly without going through Claude and outside the sandbox that applies to Claude's commands; the abcd-source-ledger-flip guard entry; adr-2610161621415708)
- dcn-26101810165101532026-10-18 — The away mode grants no push rights: the person's own standing instruction stays the authority to commit, push, and open or merge a pull request, exactly as before the mode existed (facilitator-on-delegation on the release orchestrator's ruling, itd-2610180912189125 after plan review round 2; supersedes dcn-2610181003456444, round 2's choice (a), and with it the guard entries that made setting the mode the person's act alone). The OS sandbox of spc-2610161751056096 fences only the agents abcd's runner launches, never the person's own interactive sessions, so for the sessions that actually stalled on 2026-10-16 the guard entries would never become a boundary, and their two days would buy a trust claim the plan cannot make. The fallback dcn-2610181003456444 already named is taken now, not on the live capture's evidence. Where the stop check finds owed work that needs a push (an unpushed branch, a reservation whose work no pull request carries, a behind or failing pull request once its local fix is committed), its reason tells the session to push if its standing instruction allows, and otherwise to queue the push with abcd mode queue for the person's return; a queued step that names the item's branch or record id, on word boundaries, hands that item back to the person for the rest of the window (a branch only while it holds commits its upstream lacks, so only the push waits), and the stop check stops naming it. The away mode is never treated as permission. The person-only acts gain pushing, opening or merging a pull request without a standing instruction that allows it. Who sets the mode returns to dcn-2610180913399042: the person, or a session on their word in that session; since the mode grants nothing, a mode set on a relayed word only keeps sessions working and deciding instead of asking, and the person sees who set it, from which working copy, and the literal abcd mode back on their next prompt. Making the mode the person's act alone moves to itd-2610170638184486 as named remaining work: the guard entry refusing an agent's abcd mode autonomous, its other routes (sh -c and script files), the shell-write and file-tool refusals on the away store, the refused attempts recorded and shown, the capture's checks of the person's ! command and of a relayed !, and the guard reading go run ./cmd/abcd and built abcd-<goos>-<arch> binaries as abcd for every entry, the ledger flip included, a security-relevant widening that gets its own review there (grounds: the release orchestrator's ruling of 2026-10-18 and the reviewer's addendum, both recommending the fallback; spc-2610161751056096 and adr-2610161621415708, which fence launched agents only; iss-2610170619247185, whose standing instruction already allowed pushes and auto-merge; the fallback clause of dcn-2610181003456444)
- dcn-26102220414179342026-10-22 — Anthropic's sandbox (srt) is required for the agents abcd starts on its own (product thinker's ruling of 2026-10-22, spc-2610161751056096 step 2 question a, itd-2610161621364075): every agent the runner launches runs inside srt, on macOS and Linux, and on a machine without it no such agent starts; this is adr-22's first declared exception, scoped to the runner route alone, and the per-machine off switch runner.sandbox.off stays the person's way out. The proposed marks on adr-22's consequence and on the dependencies chapter's paragraph come off
- dcn-26102220414240272026-10-22 — When srt is missing, abcd offers to install it and installs only after the person's explicit yes (product thinker's ruling of 2026-10-22, spc-2610161751056096 step 2 question b): the refusal before a run names the install it would make and asks; a yes runs the install with everything the person's account can reach, which the product thinker accepted; anything short of a yes installs nothing, and the run stays refused with the install command and the off switch named
- dcn-26102220414272412026-10-22 — A fenced agent may reach the derived host list, and the list is not announced at every run (product thinker's ruling of 2026-10-22, spc-2610161751056096 step 2 question c): the runner's model endpoint; with a go.mod, the GOPROXY and GOSUMDB hosts and the hosts GOPRIVATE and GONOPROXY name; and the hosts the person adds in runner.sandbox.hosts on that machine; the run record keeps the list, but the run does not print it at its start
- dcn-26102220414340562026-10-22 — The sandbox intent's promise is narrowed to what holds (product thinker's ruling of 2026-10-22, spc-2610161751056096 step 2 question e, itd-2610161621364075): a fenced agent changes nothing outside its own work, and cannot read the person's passwords, keys and sign-ins (the credential stores the spec denies, the macOS keychain included); the press release is rewritten to say that, and no longer says the person's other projects and settings stay out of its reach, since a fenced agent can still read most of what the person can
- dcn-26102220414399512026-10-22 — abcd signs a fenced lane's commits when it hands the work in (product thinker's ruling of 2026-10-22, spc-2610161751056096 step 2 question d): lane commits are made unsigned inside the fence, with commit.gpgsign=false in the launch's seal; at land, outside the fence, abcd rewrites each lane commit with the same tree, message and order, signed under the person's own configuration, and the judged-head check compares the rewritten chain tree for tree with the head its validators judged, so the agent never holds the signing key
- iss-2610161612203555An agent abcd starts without a terminal has nobody to answer the host's permission prompts. A command the host cannot check is then refused, and the lane stalls or fails. The product thinker chose to have abcd answer those prompts for the agents it starts, using its existing guard rules.
- iss-2610161930038953The headless approver reads the command line it is asked about, not the writes of a script that line runs: a script the approver lets run is read for the registry's hazards only, so a file the script writes, a protected one included, is not checked by the approver. The OS sandbox for launched agents, spc-2610161751056096, is the change that bounds those writes. <!-- record-lint: forward-looking -->
- iss-2610161930104545A command that writes into a directory (tar -x, unzip -d, cp -r, git apply, git checkout) is judged by the headless approver by the directory it writes into, not by the files that land inside it, so a protected file arriving inside that directory is not checked. The OS sandbox for launched agents, spc-2610161751056096, is the change that bounds those writes. <!-- record-lint: forward-looking -->
- iss-2610161930115246The file tools a headless role is granted (the --allowedTools list of the claude launch, internal/core/runner/claude.go) write files without asking the approver, so the approver's protected set does not cover what those tools write. The OS sandbox for launched agents, spc-2610161751056096, is the change that bounds those writes. <!-- record-lint: forward-looking -->
- iss-2610162027075062The agent-launch entries in the guard's registry recognise a harness by its program name, by the package that ships it, and behind the launchers the registry steps, so a harness binary copied or linked under another name and started by that name is not recognised as a harness at this layer; the OS sandbox for launched agents, spc-2610161751056096, is what bounds what such a run can reach. <!-- record-lint: forward-looking -->
- itd-2609091014076309Session and agent worktrees live in a machine-scoped store that abcd lists and reclaims, never beside the user's own projects
- itd-2610170638184486Every session keeps a checklist on disk, so no run stalls and no stall spreads
- spc-2610180914469489A session left to work on never stops while it has work