Skip to content

fix(guard): refuse a recursive delete of the root or the home, and fix three v0.11.1 gate findings - #743

Merged
REPPL merged 12 commits into
mainfrom
fix/v0111-gate-findings
Sep 29, 2026
Merged

REPPL merged 12 commits into
mainfrom
fix/v0111-gate-findings

Conversation

@REPPL

@REPPL REPPL commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

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.

  • The shell-hazard guard let rm -rf /, rm -rf ~ and rm -rf $HOME through, although its chapter promised it catches an rm -rf with 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 behind sudo, env and command. A recursive delete of *, ., .. or $PWD is now warned about, graded like git 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 --json printed two JSON documents when it refused. It now prints one: the receipt, carrying the refusal envelope's abcd, error and exit_code fields, with no empty origin.
  • The bare abcd lint help 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 list with no flags does exactly what bare abcd banlist does. 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 -f changes 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 block rm -rf $(find . -name '*.pyc'). The second is a target spelled any other way (rm -rf "$DIR"/* with DIR unset, 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-chain still 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 $HOME as a value it cannot know, so the operand compare above lost the variable's name: rm -rf $HOME was allowed, and rm -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 inside sh -c and bash -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

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
REPPL enabled auto-merge September 29, 2026 04:07
@REPPL
REPPL added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 2854550 Sep 29, 2026
13 checks passed
@REPPL
REPPL deleted the fix/v0111-gate-findings branch September 29, 2026 04:50
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant