Development record
The guard reads a script the command names before it judges the command
Context
The guard judges the text of a command line (adr-42:
a mistake filter for a cooperating agent, not a security boundary; Tier 1 blocks,
Tier 2 warns). Since iss-2609251640462464 it refuses a shell that reads its script
from a stream (printf '<blocker>' | sh, bash <(curl …)), under
interpreter-reads-stream, because the stream is text the guard read as data. That
refusal's successor tells the agent: "to run a script, save it and run it as a
file after reading it" (internal/core/guard/payload.go:1970-1971). The
over-long-command refusal says the same: "put the long text in a file and pass
the file" (internal/core/guard/guard.go:752-753).
The same text can reach a shell through a file, and today the guard allows every form of that, including the one its own successors recommend:
| command | today |
|---|---|
printf '<blocker>' > /tmp/s.sh; bash /tmp/s.sh |
allow |
source /tmp/s.sh, . /tmp/s.sh |
allow |
bash < /tmp/s.sh, bash -s < /tmp/s.sh (while cat /tmp/s.sh \| bash blocks) |
allow |
BASH_ENV=/tmp/e bash -c true |
allow |
bash --rcfile /tmp/e -i -c true |
allow |
SHELLOPTS=xtrace PS4='$(<blocker>)' bash -c true |
allow |
env 'BASH_FUNC_true%%=() { <blocker>; }' bash -c true |
allow |
ZDOTDIR=/tmp/zd zsh -c true, HOME=/tmp/h zsh -c true |
allow |
./deploy.sh |
allow |
eval "$(cat /tmp/s.sh)", bash -c "$(</tmp/s.sh)" |
warn |
So the guard's successor is the route around the guard: an agent refused
printf … | sh that follows the advice runs the same blocker one step later. A
mistake filter can leave a determined author out of scope. It cannot be the one
pointing at the door.
Registry.Check(command string) (internal/core/guard/guard.go:457) reads no
file content today and receives no directory: the Registry holds only a
worktree counter (guard.go:168-176), which a bare git stash consults
(internal/core/guard/stash.go:34-73). The hook resolves the session directory
and the host workdir (internal/surface/cli/guard.go:376-420) and then runs two
Checks, the repository registry's and the workdir registry's, folded with
guard.Strictest (:422, :448). The guard package's one file-content
precedent is the registry load, which refuses a symlinked .abcd and reads
.abcd/guard.json through fsutil.ReadGuarded under a 256 KiB cap
(internal/core/guard/config.go:22-64). The command text itself is capped at
64 KiB (guard.go:698). This record moves file reading into the verdict.
Two shapes were weighed, and the product thinker ruled on 2026-10-09 for the first ("rule A: read, then judge"):
- A: the guard reads a script the command names and judges its content;
- B: the guard blocks only the hidden spellings (
BASH_ENV,ENV), stops recommending the file form, and names script files a known limit.
Decision
We will have the guard read a shell script that the command line points a shell at, and judge the script with the registry's Tier 1 rules. Where the guard cannot be sure which bytes the shell will run, it warns (Tier 2), and it blocks only for a matched hazard or for text it knows will run unread.
1. What the guard reads. A shell-family shell (shellFamily,
payload.go:974) is pointed at a file when the command line, or a payload
segment inside it (a -c string, a script being read), has:
- a script operand (
bash f,sh f,zsh f, after the optionsshellReadsStreamwalks,payload.go:1860), orsource f/. f; - a stdin redirect into a shell with no
-cstring and no script operand, in any spelling:bash < f,bash -s < f,0< f, a descriptor duplicated onto stdin from a file opened earlier on the line, or anexecearlier on the line that redirects the shell's own stdin from a file. TodaystdinStream(internal/core/guard/tokenize.go:49-54) is set only for a pipe, a here-document, a here-string and a pipe into a group [beyond the ruling's words]; - a startup file the command line selects:
BASH_ENV=f;ENV=fon an interactive shell (-i) in any mode;--rcfile f/--init-file fon bash; for zsh, the startup files under an assignedZDOTDIRorHOME(.zshenv, and.zprofile,.zshrc,.zloginfor a login or interactive zsh); and for a login or interactive bash under an assignedHOME,.bash_profile, else.bash_login, else.profile, and.bashrc[zsh and HOME forms are beyond the ruling's words].
An assignment counts in any form the guard already resolves: a prefix, an env
wrapper, or an export earlier in the same line.
2. What the guard judges inline, without a file. Startup text the command
line carries in a variable is not a file and needs no read: SHELLOPTS or
BASHOPTS with a prompt variable bash expands (PS0, PS1, PS2, PS4,
PROMPT_COMMAND), and an exported function (BASH_FUNC_<name>%%). The guard
judges each command substitution in such a prompt, and each function body, as a
payload segment [beyond the ruling's words].
3. A direct run is classified, not read. A path run directly (./deploy.sh,
bin/tool) is stat'ed and its first 8 KiB sniffed only to classify it. A
shell-family shebang, or no shebang and no NUL byte, makes it a shell script,
read as in decision 5. A non-shell shebang, a NUL in the sniff, or a size over
the cap makes it a program, allowed unread, as every program is (decision 8).
A direct run whose path the guard cannot resolve is a program
[direct runs are beyond the ruling's words].
4. How the file is found. Check gains the directory the command runs in:
the host workdir when it exists, else the session directory, set on the
Registry by Load/LoadRepo beside the worktree counter (the precedent at
guard.go:168-176). Both of the hook's registries resolve against that same
directory; each file is read once per hook call and its verdict folded into
both. abcd guard check resolves against the process working directory, and
commands/guard.md says so. Resolution follows the shell:
- a
cd/pushdearlier on the line is followed only when its target exists at check time and the script run is chained after it with&&; otherwise the operand is unresolved, never resolved as if thecdhad failed (internal/core/guard/match.go:190-193,internal/core/guard/workdir.go:28-33); ~follows the shell's own rule: anexport HOME=earlier on the line changes it, a prefix assignment on the same simple command does not;- a
sourceoperand with no slash is searched onPATH, then the working directory, as bash does; - symlinks in the path resolve to their target, which is read with
fsutil.ReadGuarded(internal/fsutil/fsutil.go:62: regular files only, no symlinked leaf, capped).
Tilde and path expansion come from one primitive in internal/fsutil. Two
local copies exist today (internal/core/ahoy/harness_strays.go:422,
internal/core/lab/lab.go:366); the implementing change moves them there
rather than adding a third.
A shell or source operand that cannot be resolved to a path (a variable, a
command substitution, a glob) is class (2) of iss-2609281134544802, whose
verdict is a product ruling not yet made. This record does not rule it: the
implementing change allows it, as today, until that ruling sets the verdict.
5. Reading and judging a script. Only Tier 1 verdicts propagate out of a
script: a registry entry matched at command position, and the synthetic
blockers (interpreter-reads-stream, an unread payload, the depth block). A
Tier 2 speculative hit inside a script does not propagate. A script is a
reviewed artefact rather than a line being typed, and the rationale for Tier 2
(an unknown command may exec its arguments, adr-42) is weaker still for text the
agent did not write on this line. A propagated block names the script, the line
and the entry.
The guard's verdicts on what it finds:
| finding | verdict |
|---|---|
| a registry blocker at command position in the script | block |
| the script was written earlier on the same line (decision 6) | block |
| the script does not exist | allow, with a diagnostic naming it [differs from the pitch] |
| a startup file the line selects does not exist | allow, silently (the shell skips it) |
| exists but cannot be read (permission, not a regular file) | warn |
| a shell-pointed file that is binary (NUL in the first 8 KiB) | block: a shell will run bytes the guard cannot judge |
| a shell script over 256 KiB | warn: not read, size named [differs from the pitch] |
| the read budget is spent (decision 7) | warn, through the fail-loud path |
6. Written, then run, on one line. A script is written-then-run when an
earlier segment on the line, payloads included, writes a path that resolves
(decision 4) to the script or to a directory above it: a redirect target, or the
target operand of a listed writer (tee, cp, mv, install, dd of=,
curl -o/-O, wget -O, sed -i, patch, git checkout/restore/clone,
tar -x, unzip). The file the guard reads at check time is not the file that
runs, so this blocks. A write target the guard cannot resolve warns. Reading the
script first (cat s.sh && bash s.sh) is not a write and stays allowed. The
writer list is an enumeration in the sense of adr-42 decision 5: incomplete by
design, extended over time, and named in decision 8.
7. Cost is bounded by depth and by an aggregate budget. File reads happen
only from Tier 1 command positions, never from a speculative start
(internal/core/guard/speculate.go:50). A script that points a shell at a
script is read in turn, sharing one depth counter with the payload families'
maxPayloadDepth (2 today, payload.go:28); past it the guard blocks, as it
does for nested payloads. A file already on the current read stack is a cycle
and is skipped, not counted, since everything in it was already read. One
budget per Check caps the total at 16 files and 1 MiB read; past it the guard
warns. The implementing change extends the workTally cost guards
(internal/core/guard/work.go) to bytes read and adds the case to
BenchmarkCheck, keeping the bound adr-42 decision 3 makes load-bearing.
8. The check-to-run race is accepted, and the limits are named. The file
can change between the guard's read and the shell's. Closing that needs the
host to run a frozen copy, which is host-specific and outside abcd (adr-25).
Under adr-42's threat model the race is accepted; decision 6 closes the one form
a single line can express. These stay unseen, and commands/guard.md and the
SHELL rules domain say so:
- other interpreters' files:
python3 f.py,node f.js,ruby,perl,make(a Makefile), package-manager scripts (npm run); - a program run directly or found through
PATH, including aPATHassignment on the line that changes which file a bare name finds; - the files a shell loads implicitly from the account's real
HOME(~/.bashrc,~/.zshenv,/etc/profile). Asourceoperand naming one is read like any other; - file text substituted into a command string (
eval "$(cat f)",bash -c "$(<f)"), which keeps today's warn; - the check-to-run race, and any writer missing from decision 6's list;
- any spelling of the class not listed in decisions 1 and 2.
9. The successors change. The stream block's successor reads: "Run the
commands directly, or pass them with sh -c '<commands>' so the guard reads
them. A script file is read and judged when it is run, so write it in one
command and run it in the next." The over-long-command successor
(guard.go:752-753) stops recommending a file without a size: "split the
command, or put the text in a script under 256 KiB and run it in a separate
command." Each new block names its own fix: for write-then-run, split the line;
for a binary file handed to a shell, run the program directly.
10. The sibling sweep and the warn-rate check. The implementing change
sweeps every startup file, startup-expanded variable and option a shell-family
shell honours, against the installed bash (3.2 and 5.x), sh and zsh manuals:
at least everything in decisions 1 and 2. Each is pinned by a guard test watched
fail first, or recorded under decision 8 with its reason. It also adds a corpus
test that runs the repository's own scripts/*.sh, .githooks/* and
hooks/*.sh through decision 5, the way the Makefile runs them, with a pinned
warn ceiling in the corpus_test.go shape, so the warn-rate STOP of adr-42
decision 8 is measured rather than assumed.
11. Two refinements the product thinker ruled while it was built
(2026-10-09). Decision 5 carries out of a script only block-level verdicts:
a registry entry that only warns, met inside a script, stays inside it with the
Tier 2 hits, so a script that runs git clean on its own scratch does not make
every run of it warn; the repository's own scripts then warn on none. Decision
6 applies to a direct run too: a file the line writes and then runs by path
blocks whatever the file is, closing the gap decision 3 left for a file that is
absent, or a program, at check time.
Alternatives Considered
- B: name script files a known limit, block only
BASH_ENV/ENV. Small and certain, but the successors keep pointing at an unguarded route, and the other startup spellings stay open one by one. Rejected by the product thinker on 2026-10-09. - Refuse every script run the guard has not read (block
bash foutright). Closes the class, but breaks the ordinarybash ./build.sha cooperating agent runs all day, which turns a mistake filter into an obstacle and invites the registry-disable escape. Rejected. - Block a missing, oversized or binary file in every position (the pitch's
wording). Simple to state, but measured as false blocks: a compiled binary run
by path (
bin/abcd-darwin-arm64is 14 MB),./configurescripts over 256 KiB, the conditional-source idiom[ -f .env ] && source .env, andHOME=<tmp>test lines whose startup files do not exist. Replaced by decisions 3 and 5. - Propagate every verdict from a script, Tier 2 included. Measured: this
repository's own
scripts/check-issue-resolution.sh, run as theMakefileruns it, warns on Tier 2 speculation with no hazard in it. Rejected in favour of decision 5. - Run a frozen copy (the guard copies the script and the host runs the copy). Closes the race, but needs the host to rewrite the command it runs, which no host abcd supports does today and adr-25 keeps out of the core. Rejected for now; decision 8 names the race instead.
- Warn, never block, on the file forms. Keeps the guard textual, but a warn is advice the agent can step past, and it leaves the successor's route open at Tier 2. Rejected.
Consequences
- The verdict depends on file content as well as command text.
Checkgains a working directory on theRegistryand a filesystem read bounded by decision 7, throughfsutil.ReadGuarded, the primitive the registry load already uses. - A few commands allowed today block: a script with a registry blocker in it, write-then-run on one line, a binary handed to a shell. Each block names the script, the line and the fix.
- Guard tests grow a fixture tree of scripts; every row of the Context table is pinned by a test watched fail first, and the repository's own scripts are a corpus with a warn ceiling.
commands/guard.md, theSHELLrules domain (generated from the registry), and both successor texts change in the same change.- Tilde and path expansion gain one home in
internal/fsutil. - The class (2) ruling owed on iss-2609281134544802 now also decides how an unresolved script operand is judged.
Frontmatter
| id | adr-2610091150447054 |
|---|---|
| type | adr |
| status | accepted |
| date | 2026-10-09 |
.abcd/development/decisions/adrs/2610091150447054-the-guard-reads-a-script-the-command.md
open on GitHub ↗ · commit history ↗
References3
- adr-25The LLM is host-delegated by default; oracles are opt-in adaptersrelated
- adr-42The guard's parse layer is a mistake filter, not a security boundary — matching is two-tier, and completeness is abandoned as a goalrelated
- itd-103**abcd teaches repo agents the shell commands they must never run — and blocks them when they try.** An agent that runs `cd scratch && rm -rf *` is one failed `cd` away from deleting the working tree, and the facilitator watching the session usually has no way to know that. abcd ships a hazard registry: each entry a dangerous command pattern, its severity, the safe successor, and a plain-language why. One registry, two planes. The rules loader injects the matched safety rules before shell-heavy work, so the agent is taught the safe form up front; a deterministic guard — a core verb any harness hook can call — refuses a matching command at execution time and replies with the safe successor, so the block itself is the lesson. Hosts without hook support still get the teaching plane; hosts with hooks get both, wired in at install. The registry grows from reality: a facilitator who sees something scary needs to know exactly one move — capture it — and recurring captures promote patterns into the bundled defaults, each entry shipping with a fixture that proves its guard fires. "I couldn't have told you why that command was dangerous," said Nia, a facilitator. "I didn't have to. The guard refused it, told the agent what to run instead, and told me why in words I understood. The one time something new scared me, I captured it and moved on."related
Referenced by1
- adr-2611011813190527A repository declares a sequence's shape but never a widening flagrelated
mentions3
- 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)
- iss-2609251640462464A blocked command piped as text into a bare shell at the top level is allowed: echo or printf of the command string piped into sh, or into bash -s, runs it, but pipesIntoInterpreter is consulted only inside execute-a-string payloads, and the top-level segments carry no record of which operator joined them, so the guard cannot tell a shell reading the pipe from one running a script file without tracking pipes. Found by review-guard finding 6 (pre-existing).
- iss-2609281134544802The shell guard reads a parameter expansion as an unknown word (iss-2609251824244354, lane drainG3) for the flags and program names its value can spell, but not as data an earlier command carried into the variable, and four classes stay unread: (1) a pid list a search printed into a variable (p=$(pgrep make); kill $p, or pgrep make | while read p; do kill $p; done), the plain-variable half [dcn-2609250000000016](../../decisions/dcn-2609250000000016-correction-to-the-two-entries-above-on.md) (c) parked on iss-2609251824244354; (2) a stream path in a variable handed to a shell or source as its script (bash "$f" after f=<(curl ...)); (3) shell text a variable holds, run through eval "$X", sh -c "$X", or placed in a string a shell runs, whose value may hold a separator or quotes (eval "$X" allows, as it did before the reading); (4) pkill or killall as a variable's value standing as the program with an operand ($P make), and a variable-named bare interpreter inside a string. Reading each would refuse ordinary commands: the false-positive sweep of 4,315 inputs showed 22 new program-name-unknown blocks ("$GO" build, exec "$BIN", $EDITOR notes.md) and 15 new interpreter-reads-stream blocks (bash "$SCRIPT") before these readings were left out. Classes 1 and 3 carry the same cost, from the sweep's everyday-variable lines, each of which allows today: a reading of class 1 cannot tell a pid a search printed from one the script recorded, so it would refuse kill "$pid", kill -TERM "$PID" and kill -- -"$pg" (a background job's pid, a pidfile's, a process group's); a reading of class 3 cannot tell a hostile string from a composed one, so it would refuse eval "$cmd", the line that runs a command a script built up. Each class is a block, warn or allow call the product thinker has not ruled on.