fix(guard): refuse a recursive delete of the root or the home, and fix three v0.11.1 gate findings - #743
Merged
Conversation
The bundled registry's one rm entry fired only behind a cd chain, so
`rm -rf /`, `rm -rf ~` and `rm -rf $HOME` were an allow while the guard
chapter's headline promised the catch of an rm -rf with an unlucky glob.
A new additive Pattern field, arg_values, matches an operand by its exact
word (known text; empty and dash-led values are refused at load). Two
bundled entries use it:
- rm-rf-root-or-home (blocker): /, /*, ~, ~/, ~/*, $HOME, ${HOME} and
their / and /* forms, under the recursive flag alone.
- rm-rf-working-directory (warn, graded like git clean): *, ., ./, ./*,
.., ../, ../* and .*, under the recursive flag alone.
A target printed whole by a substitution is read by its known text, a
third recorded operand residual beside the + refspec prefix, so
`rm -rf $(find ...)` stays an allow. The cost tests' per-byte bar moves
from 20 to 24 because the operand walk reads every token once per entry.
Tests that used a bare `rm -rf *` as their ordinary-work probe now use
`rm -rf ./build`; a host workdir is still never read as a cd. The
decision is appended to DECISIONS.md; the guard chapter, the plugin page
and the check's help state the coverage and the residual.
Refs: iss-2609282105242542
Assisted-by: Claude:claude-opus-5-5
… home Resolves: iss-2609282105242542 Assisted-by: Claude:claude-opus-5-5
`abcd update --json` on a refusal printed the receipt and then Run's
error envelope: two JSON documents on stdout where a machine reader
expects one. A refusal under --json is now one document, the receipt
with the global refusal envelope's three fields ("abcd": "error",
error, exit_code) beside its own, and the command returns an exit error
with no message so Run adds nothing after it. Text mode is unchanged.
The receipt's origin is omitted when empty, since a refusal raised
before any fetch reached no release origin. The update chapter and the
plugin page state the shape.
Refs: iss-2609282105241960
Assisted-by: Claude:claude-opus-5-5
…cument Resolves: iss-2609282105241960 Assisted-by: Claude:claude-opus-5-5
The naming chapter forbids a plain unfiltered `<verb> list` but prescribes no remedy, and each remedy changes a shipped verb, so the choice is the product thinker's. The record carries deferred_after v0.11.1 and the reason, and the decision is appended to DECISIONS.md. Refs: iss-2609282105240081 Assisted-by: Claude:claude-opus-5-5
The bare `abcd lint` sentence said it checks the conventions "every target included", while repolint.DefaultRules carries no outbound rule and the lint chapter says the outbound target stays out of the bare run because its subject is text the caller hands it. The sentence now reads "every target but outbound", in the manifest, the plugin page's frontmatter, and the regenerated CLI reference and surface snapshot. Refs: iss-2609282105240689 Assisted-by: Claude:claude-opus-5-5
…s left out Resolves: iss-2609282105240689 Assisted-by: Claude:claude-opus-5-5
The surface chapter's prose may not state shape above the generated appendix, and the sentence added for the one-document refusal named the --json flag. It now says "in the JSON form". Refs: iss-2609282105241960 Assisted-by: Claude:claude-opus-5-5
…n on the working directory by name
The security review of this branch found `rm -rf ~/.*`, `$HOME/.*` and
`${HOME}/.*` an allow: every dotfile and dot-directory in the home (keys,
shell and tool settings, abcd's own store), in a shape people type rather
than an obfuscation, while the working directory's `.*` was already a warn.
The three words join rm-rf-root-or-home's operands, a blocker.
It also found the working-directory warn firing on `.` and `..`, which rm
refuses by itself, while `rm -rf "$PWD"`, the spelling that deletes, passed.
`$PWD`, `${PWD}`, both with `/*`, and the globs `*/`, `./*/` and `./.*`
join rm-rf-working-directory's operands, a warn. `$PWD` is compared as the
known word, the same design as `$HOME`.
The review's over-match list stays an allow and is pinned: `rm -rf .git`,
`.venv`, `~/.cache/x`, `"$HOME/.cache/x"`, `"$PWD/build"`. The chapter and
the command page list the added operands. rm-rf-after-cd-chain is left
as it is, a deliberate separate act (recorded 2026-09-28).
The cost suite's maximum stays 20.07 units per byte under the bar of 24.
Refs: iss-2609282105242542
Assisted-by: Claude:claude-opus-5-5
Two textual conflicts, resolved by hunk with both sides kept: - internal/core/guard/unknown.go (header comment): this branch's arg_values residual sentence and main's sentence that a word wholly a variable reads the same way (variableCarried). - .abcd/development/brief/04-surfaces/17-guard.md (residuals paragraph): main's "a command substitution or a variable standing where a flag would be" and this branch's `$(echo /)` and other-spelling residuals. go generate ./internal/surface/cli reports no drift; build and vet pass. The guard's tests FAIL at this commit, knowingly: main's 7cf24a4 writes a parameter expansion as the unknown-value mark, so arg_values no longer sees `$HOME` or `$PWD` as written. TestBundledEntriesPassAdmissionGate, TestRecursiveDeleteOfRootOrHomeBlocks, TestRecursiveDeleteOfTheHomesDotfilesBlocks, TestRecursiveDeleteOfTheWorkingDirectoryWarns and TestEverySubstitutionPositionKeepsTheVerdict fail here. The next commit carries each word's written spelling of its variables to arg_values and fixes them. Assisted-by: Claude:claude-opus-5-5
Main writes a parameter expansion as the unknown word's mark, so the
arg_values compare read `rm -rf $HOME` as an empty operand (allow) and
`rm -rf $OUT/` as `/` (block). The tokenizer now also records, per word
holding a variable, the word as the line wrote it (segment.spelled): each
variable's mark replaced by its expansion's text, filled at the three
addVar sites and in parameterExpansion; a substitution's mark dropped as
knownText drops it; a name the next byte would extend braced; a mark with
no known name kept as the unknown mark, which names nothing. Only
argValueMatches reads it (writtenOperand); every token, and so every other
matcher's input, is unchanged.
A string handed to a shell carries only unnamed marks (payloadView), so
expandPayloads also reads the string with its variables written out
(spelledView, namedPayloads) and gives each word of the mark reading the
spelling of the word at the same place (spellPayload), only when both
readings have the same segments and words and the known text fits
(fitsWritten). `sh -c "rm -rf $HOME"` blocks again, nested strings
included.
Verdicts: over 12,515 inputs (every string literal of the guard tests,
both corpora, every bundled fixture, each also as `sh -c "…"` and
`bash -c '…'`), main's tip and this commit with the two rm-target entries
removed answer identically; against the merge commit, the only changes are
the 48 rm-target transitions the new tests name.
workPerByteBar stays 24: the merged registry measures 20.3 units per byte
on the unknown dash-word shape at the merge commit and here alike (20 fails
it); strings with variables cost up to about 2 units per byte more, linear.
Residuals (a target spelled any other way): a brace expansion's words and a
default (`${HOME:-/}`) have no written spelling.
Refs: iss-2609290321312087
Assisted-by: Claude:claude-opus-5-5
…written The fix is 9118e67: the arg_values compare reads each operand's written spelling of its variables, and nothing else reads it. Resolves: iss-2609290321312087 Assisted-by: Claude:claude-opus-5-5
REPPL
enabled auto-merge
September 29, 2026 04:07
REPPL
added a commit
that referenced
this pull request
Sep 29, 2026
Main carries #743 (gateFix), #742 (drainAhoy) and #746 (integ14). One conflict, .abcd/config/reading-presets.json (widening's measured figures): main's figures taken on the conflict hunk only; the windows are re-measured at the integration tip afterwards. DECISIONS.md and CHANGELOG.md are main's byte for byte; [Unreleased] is empty. Assisted-by: Claude:claude-opus-5-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The v0.11.1 release gate found four things wrong with what abcd tells people. Three are fixed here, and the fourth waits for a product decision.
rm -rf /,rm -rf ~andrm -rf $HOMEthrough, although its chapter promised it catches anrm -rfwith an unlucky glob. A recursive delete of the filesystem root, the home directory or every dotfile in the home (rm -rf ~/.*) is now blocked, in every flag spelling and behindsudo,envandcommand. A recursive delete of*,.,..or$PWDis now warned about, graded likegit clean, because emptying a build directory that way is ordinary work. Deleting a named directory (rm -rf ./build,rm -rf /tmp/x,rm -rf ~/.cache/x,rm -rf .git) is still allowed.abcd update --jsonprinted two JSON documents when it refused. It now prints one: the receipt, carrying the refusal envelope'sabcd,errorandexit_codefields, with no emptyorigin.abcd linthelp said it runs every target, but the outbound target was never part of the bare run. The help now says "every target but outbound".abcd banlist listwith no flags does exactly what bareabcd banlistdoes. The naming chapter forbids that shape, but every remedy changes a shipped verb, so the record is deferred past v0.11.1 until the product thinker picks one.For the security reviewer
The guard change adds one optional field to the registry's pattern schema,
arg_values: some operand must be exactly one of the listed words, compared by its text before the shell expands it. An empty or dash-led value is refused at load. The field is used by two bundled entries:rm-rf-root-or-home(blocker):/,/*,~,~/,~/*,~/.*,$HOME,$HOME/,$HOME/*,$HOME/.*,${HOME},${HOME}/,${HOME}/*,${HOME}/.*.rm-rf-working-directory(warn):*,*/,.,./,./*,./*/,./.*,..,../,../*,.*,$PWD,$PWD/*,${PWD},${PWD}/*.Both entries need only the recursive flag, not
-f: an agent's stdin is not a terminal, so rm never prompts it, and-fchanges nothing about what gets deleted. Two cases are deliberately not caught, and both are recorded in DECISIONS.md. The first is a target printed whole by a substitution (rm -rf $(echo /)), which is read by its known text, the same way a+refspec prefix is. Reading it as every possible target would blockrm -rf $(find . -name '*.pyc'). The second is a target spelled any other way (rm -rf "$DIR"/*withDIRunset,rm -rf /?*). The cost tests' per-byte work bar goes from 20 to 24, because the operand walk reads every token once per entry; the measured maximum is 20.3 after the merge with main.rm-rf-after-cd-chainstill needs-f; changing it is left as a separate act, recorded in DECISIONS.md.Re-merge and fix
This branch merges main (#741). Main's guard reads a variable such as
$HOMEas a value it cannot know, so the operand compare above lost the variable's name:rm -rf $HOMEwas allowed, andrm -rf $OUT/was blocked as a delete of the root. The compare now reads each operand as the line wrote its variables ($HOME,${PWD}), in a record only that compare reads. A substitution's output is dropped as before, and a variable whose name is not known matches nothing. For a string handed to a shell (sh -c "rm -rf $HOME"), the string is read a second time with its variables written out, and the names are taken only where both readings agree word for word. Every other entry reads exactly what main reads: over 12,515 inputs (every string in the guard's tests, both corpora and every bundled fixture, each also insidesh -candbash -c), main's tip and this branch, with the two new entries removed, give identical verdicts. A brace expansion's words and a default such as${HOME:-/}have no written spelling and stay among the residuals. The per-byte work bar stays at 24, because the merged registry measures 20.3 on its costliest shape with or without this fix.Resolves: iss-2609282105242542
Resolves: iss-2609282105241960
Resolves: iss-2609282105240689
Resolves: iss-2609290321312087
Refs: iss-2609282105240081
Assisted-by: Claude:claude-opus-5-5