Development record
guard registry coverage gaps found while wiring itd-103 (registry content, not matching semantics): a push whose refspec carries a leading plus is a force in disguise and no entry describes it; xargs, timeout and exec are absent from the matcher's wrappers set, so a hazard launched through one of them is not seen; a backtick command substitution is not followed, while the dollar-paren form is; and a wrapper that IS in the set defangs an entry the moment it carries its own flags, because only the wrapper name is stepped over — `sudo <hazard>` is seen, `sudo -u bob <hazard>` is not, and the same holds for `env -i` and `time -p`. That last one is the sharpest: it turns an entry the registry does describe into an allow with one extra token, and it is the only item here that a facilitator would reasonably assume was covered. Candidates for the admission gate as the registry grows from reality; the wrapper-flag item is matcher-side (`commandOf` in internal/core/guard/match.go), not registry content.
guard registry coverage gaps found while wiring itd-103 (registry content, not matching semantics): a push whose refspec carries a leading plus is a force in disguise and no entry describes it; xargs, timeout and exec are absent from the matcher's wrappers set, so a hazard launched through one of them is not seen; a backtick command substitution is not followed, while the dollar-paren form is; and a wrapper that IS in the set defangs an entry the moment it carries its own flags, because only the wrapper name is stepped over — sudo <hazard> is seen, sudo -u bob <hazard> is not, and the same holds for env -i and time -p. That last one is the sharpest: it turns an entry the registry does describe into an allow with one extra token, and it is the only item here that a facilitator would reasonably assume was covered. Candidates for the admission gate as the registry grows from reality; the wrapper-flag item is matcher-side (commandOf in internal/core/guard/match.go), not registry content.
Re-scoped in place (2026-08-01, the v0.5.0 guard round). Three of the four
sub-parts shipped on v050/iss-159; the fourth was attempted, failed review
three times, and was reverted. The entry stays OPEN on that one item rather than
being resolved, following the iss-96 precedent — a capture resolve would have
to state a resolution for scope that is not settled.
Shipped.
- The
+refspecforce in disguise — a new bundledgit-push-force-refspecentry, carried by a new optionalarg_prefixespattern field, sogit push origin +main:mainblocks whileorigin main:maindoes not (3672c5f). xargs,timeoutandexecjoined the matcher'swrappersset, withtimeout's mandatoryDURATIONoperand stepped over too —timeout 30 rm -rf /had read as a command called30(cd869e4).- A wrapper's OWN flags no longer defang the entry behind it: the walk steps over
the value-taking flags each wrapper documents, off an explicit per-wrapper
table, so
sudo -u bob <hazard>is seen where it previously read-uas the command name (cd869e4). The complementary miss — a value flag the table does not name, such as the bundled short formsudo -Hu bob— is a stated limit on all four coverage surfaces, and is a non-match rather than a false block.
Still open: the backtick sub-part. "A backtick command substitution is not
followed, while the dollar-paren form is" is unchanged. It was implemented, then
extended with a frame/stack mechanism meant to let a substitution's content be
checked as its own segment while the ENCLOSING command's tokens survived the
boundary; three rounds of adversarial review (correctness and security, fresh
independent reviewer each round) each returned a genuine guard-bypass regression
traceable to that redesign. In order: a substitution written before trailing
flags truncated the enclosing segment, turning rm + substitution + -rf * from
block into allow; the frame fix for that renumbered the enclosing command's chain
when a newline fell inside a substitution, defeating after_cd entries; and,
worst, a substitution in LEADING command position left a bare $ as the
enclosing command's argv[0], so commandOf returned "$" and NO registry entry
could match — a defeat of the whole registry, not of one entry. A nested bare (
inside $( … ) mis-popped the frame and re-opened the first bug in a narrower
shape, and an unterminated backtick failed OPEN. Patching a fourth time inside
the same round was refused; both forms are back to main's behaviour exactly, so
the LITERAL ask here — parity with $( … ) as it actually behaves — is met by
neither form having moved, and the gap is disclosed as a v1 limit on all four
coverage surfaces again.
Update (v0.6.7 — gh-312 / iss-2608270500192596). Backtick following was
re-implemented and this time it holds: $(…) and backticks are both followed
into command position, and the three revert-era regressions are gone —
empirically, a leading-position substitution no longer defeats the registry
($(true) gh repo delete blocks), the newline-in-substitution chain blocks, an
unterminated backtick blocks rather than failing OPEN, and a nested paren blocks.
The four coverage surfaces now state backticks ARE followed. What stays OPEN here
is therefore narrowed to the deep sub-part below ONLY: a substitution written
before an enclosing command's trailing flags still truncates them for BOTH forms
(cd s && rm $(true) -rf * allows), the pre-existing $(…) gap this record
already names — the "neither form followed" framing above is superseded by this
update and retained only as history.
Carried forward as its own work, not as a patch to this one: a design that
follows the payload AND preserves the enclosing command's tokens, chain index and
command position across the substitution boundary. Note for whoever takes it that
$( … ) on main already loses the flags written after a substitution
(rm $(true) -rf * does not read as a recursive force delete) — a pre-existing
gap this round surfaced and did not introduce, and the reason the two problems
are one problem.
Grounds
- pursued: a substitution written before trailing flags keeps them in the enclosing command; shown wrong by any substitution shape in TestSubstitutionKeepsTheEnclosingCommandWhole answering allow
Frontmatter
| id | iss-148 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | minor |
.abcd/work/issues/resolved/iss-148-guard-registry-coverage-gaps-found-while.md
open on GitHub ↗ · commit history ↗
mentions9
- dcn-26080100000000022026-08-01 — guard round (iss-148, iss-159, iss-144): the matcher's wrapper walk now steps over a wrapper's OWN arguments rather than its name alone, off two explicit tables — the flags each wrapper documents as taking a value, and the mandatory operand no flag stepping can reach (`timeout [OPTIONS] DURATION COMMAND...`, where `timeout 30 rm -rf /` read as a command called `30`). That was the sharpest of the three items and the reason to take it first: `sudo -u bob <hazard>` read `-u` as the command name, so one extra token turned an entry the registry DOES describe into an allow, and a guard that has stopped refusing looks identical to one with nothing to refuse. The complementary miss is deliberate and now stated rather than implied — a value flag the table does not name (a bundled `sudo -Hu bob`) is not stepped over, and a miss is a non-match, never a false block. `xargs`, `timeout` and `exec` joined the wrapper set. Backtick substitution reaches command position through the same segment flush the grouping parens already gave `$( … )` by accident, scoped to parity with today's behaviour (unquoted only) rather than POSIX completeness, so the two spellings of one substitution stop disagreeing. Registry content took four additive Pattern fields, each optional and per-repo overridable like the rest: `subcommand2` for a two-level grammar (`gh repo list` and `gh repo delete` share their first level and only one is a hazard), `flag_values` for a flag's SETTING rather than its presence (presence alone would force a choice between missing `-X DELETE` and refusing `-X GET`; the value is compared without regard to case, since a constraint a change of case walks past is not one), `arg_paths` for a root segment AND an exact depth (the depth is the entry's scope: `repos/{owner}/{repo}` IS the repository, while DELETE on a branch ref under it stays ordinary work), and `arg_prefixes` for a hazard carried by an operand with no flag to look at (`git push origin +main:main`). The empty form of each is a load-time rejection, because an empty flag group defangs and an empty prefix over-blocks, and both are invisible in the file. Two behaviours are pinned as tests rather than left to be rediscovered: an api path written as a fully-qualified URL is NOT matched (a stated limit, the family of the payload gap), and `--help` is not an exemption anywhere in this registry — exempting it would mean teaching every entry which of its flags mean 'do nothing', and a guard that reasons about intent can be argued out of refusing. iss-144 needed no code at all: its incident shape was already known-bad fixture #1 and the incident capture already known-good #1, so the only change was adding the three-step form it actually occurred in (mkdir → cd → `rm -rf *`) to the corpus, making the resolve verifiable rather than asserted. Residual and deliberately not acted on: a `cd` in one tool call and an `rm -rf *` in the next is still an allow, since the tokenizer never sees two invocations as one candidate — a harness-level concern, not a registry one.
- dcn-26080100000000042026-08-01 — guard batch (iss-159/144/148), REVERT of the backtick sub-part after a THIRD adversarial round; the other three sub-parts stand. Following backtick command substitution went in, then grew a frame/stack mechanism intended to hold two properties at once — a substitution's content checked as its OWN segment (so a hazard hidden inside one is matched) while the ENCLOSING command's tokens, chain index and command position survived the boundary. Three rounds, three fresh independent reviewers, three BLOCKER findings, all in that mechanism rather than in one instance of it: (1) a substitution before trailing flags truncated the enclosing segment, so `rm` + substitution + `-rf *` went block to allow; (2) the frame fix for (1) renumbered the enclosing command's chain when a newline fell inside a substitution, defeating every `after_cd` entry (`cd scratch && rm -rf $(\nfind . -name x\n)`); and (3), the one that decided it, a substitution in LEADING command position left a bare `$` as the enclosing command's argv[0], so `commandOf` returned `"$"` and NO entry could match — that is not a hole in `rm-rf-after-cd-chain`, it is a hole in the registry, reachable by prefixing any hazard with `$(true)`. Two more in the same round: a bare `(` nested inside `$( … )` mis-popped the frame and re-opened (1) in a narrower shape, and an unterminated backtick failed OPEN. DECISION: revert rather than patch a fourth time inside one round. A mechanism that has produced a security-relevant regression on every attempt is not converging, and each fix has been strictly worse than the last in blast radius — the third defeats the entire registry. `internal/core/guard/tokenize.go` is restored byte-for-byte to `main`: no backtick handling at all (literal text), `$( … )` exactly as it was, and the four guard.json fixtures the frame mechanism added removed with it. The two claims of the entry above are therefore WITHDRAWN, not superseded: backtick substitution does NOT reach command position, and the enclosing command does NOT survive a substitution. The literal ledger ask ("a backtick command substitution is not followed, while the dollar-paren form is") asked for PARITY with `$( … )` as it behaves today, and parity is what leaving both forms untouched delivers — the gap is disclosed again as a stated v1 limit on all four coverage surfaces (brief, plugin command, CLI long help + generated reference, package map), which is the honest form of it. Recorded rather than quietly carried: `$( … )` on `main` already loses the flags written AFTER a substitution — `rm $(true) -rf *` does not read as a recursive force delete — a pre-existing gap this round's review process surfaced and did not introduce, undisclosed before it and undisclosed still, because closing it is the same design problem as following a backtick and the two should land together or not at all. iss-148 is re-scoped IN PLACE back to open on that one sub-part (the iss-96 precedent: a `capture resolve` would have to state a resolution for scope that is not settled), with the three shipped sub-parts and their commits named in the entry; iss-144 and iss-159 are untouched and stay resolved, neither being implicated in any review finding. Deferred as its own future work, and explicitly NOT a patch to the reverted code: a substitution model that follows the payload AND preserves the enclosing command across the boundary, which needs the tokenizer to carry a command-position stack rather than a segment flush — the reason three attempts to bolt it onto the flush all leaked.
- dcn-26082000000000022026-08-20 — bughunt round 2 (branch bughunt-b/round-2): captured the round-2 records (iss-346..iss-357, with the two colliding ids re-minted as iss-358/iss-359), fixed
- iss-96Now that transcripts are captured automatically on every session end, the scanner's secret-pattern coverage becomes load-bearing in a way it was not when capture was a manual verb nobody ran. Verified by live test: the bundled patterns DO catch anchored tokens (AKIA... access key IDs, ghp_/gho_/sk-ant- style prefixes) and absolute home paths, but they do NOT catch unanchored high-entropy values — an AWS SECRET access key (the 40-char base64 value, no prefix), a bare password, or a generic API token with no recognisable prefix all pass through into the store verbatim. This is the standard prefix-matching limitation and is pre-existing, not a regression; the point is that the blast radius changed. Consider entropy-based detection or the opt-in gitleaks adapter for the transcript path specifically, where the input is unstructured prose rather than curated source.
- iss-159hazard registry has no entries for destructive GitHub remote operations: 'gh repo delete' and 'gh api -X/--method DELETE repos/{owner}/{repo}' both verdict allow. Field evidence: the user-level guard-bash.sh hook blocks both (repo deletion is human-only; second layer behind the missing delete_repo scope) — that hand-wired hook is the prior art to lift into the bundled registry defaults, same prototype-to-product path as itd-74. Registry entries should mirror the hook's nuances: quote-tolerant token matching (already native), depth-limited api paths (DELETE on deeper repo subpaths stays allowed), and the block message naming the human-only successor.
- iss-356round-2 fixed nitpick batch: guard check help omits the disclosed backtick limit; urlguard misses CGNAT 100.64/10 and other RFC 6890 ranges; readHookInput misreports an over-cap payload as malformed (iss-201's class at the shared helper); repolint privacy-hygiene silently skips textual files over the 4 MiB cap; capture reCommitSha refuses 64-hex commits; launch Short hides that --dry-run is required; release.yml verify-tag comment overstates what gh verifies; release.yml header says dispatch runs only the rehearsal job; dependabot covers no pip ecosystem for docs; brief pointer lines enumerate five of nine internals chapters; phase-6 and 06-lint carry change-narration blocks; 05-internals cites a ledger entry that does not exist; 03-evidence names the retired activity record nitpick documentation
- iss-2608270500192596abcd guard tokenizer has no backtick command-substitution case, so a blocker inside backticks (e.g. `gh repo delete ...`) passes unflagged. GitHub mirror: #312
- iss-2608271140346423The abcd guard coverage prose in three user-facing places still describes the pre-gh-312 backtick limitation: guard.go's 'guard check --help' string and the generated docs/reference/cli/commands.md say the top-level backtick substitution is not followed (a disclosed v1 limit, iss-148), and commands/guard.md lists 'one inside a backtick substitution' among what an allow does not see. The gh-312 fix now follows top-level backticks into command position like the dollar-paren form, which the v0.6.7 Security changelog advertises — so the shipped help and docs contradict the release's own changelog and under-report a security capability.
- 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."