Skip to content

fix(hooks): only guard a verb in command position - #33

Open
Xavier876 wants to merge 1 commit into
codewithmukesh:mainfrom
Xavier876:fix/bash-guard-command-position
Open

Xavier876 wants to merge 1 commit into
codewithmukesh:mainfrom
Xavier876:fix/bash-guard-command-position

Conversation

@Xavier876

Copy link
Copy Markdown

pre-bash-guard.sh matches its guarded phrases anywhere in the command text, so
it blocks commands that read or write about the thing it guards.

Every one of these is blocked today, and none of them deletes or discards
anything:

grep -n 'Remove-Item\|rm -rf\|BLOCKED' hooks/pre-bash-guard.sh
grep -rn 'git reset --hard' docs/
git commit -m "docs: explain why rm -rf is blocked"
confirm -rf task

The first is a command that reads the guard's own source. The last is any word
ending in the letters r and m followed by a flag.

I hit the first one while reading this file to understand a different block, and
the third while writing the commit for this very PR. A guard that blocks reading
and documenting itself teaches people to route around it, which is worse than
not guarding.

This is the same class of defect #23 fixed in pre-commit-antipattern.sh — a
naive grep over text that holds both code and prose about code. The same answer
applies here.

The change

Four passes, in order:

  1. Heredoc bodies collapse to a placeholder. A commit message written with
    cat >msg <<'EOF' is prose, and prose about a guard quotes what it guards.
    Delimiter lines stay so the shape of the command is still readable.
  2. Quoted spans collapse too. That separates a commit message carrying a
    phrase from a command that is one, and reduces a grep alternation, escapes
    and all, to one harmless token.
  3. A shell wrapper re-opens the scan. bash -c "…" hands its argument back
    to a shell, so the unmasked text is scanned when one is present. The wrapper
    is itself looked for in the masked text and in command position, so prose
    that merely names one does not switch the scan back on.
  4. A verb only counts in command position — the start of the command, or
    after something that begins one. This is what stops confirm -rf.

The rm allowlist reads the unmasked text, being the one check that needs the
target path, and now also accepts a quoted target — "node_modules" is the same
target as node_modules, and quoting is what anybody does once a path has a
space.

One adjacent fix: the no-jq fallback now undoes JSON string escapes. Without
it a multi-line command arrives as a single line carrying a literal \n, so
nothing that reads the command line by line can see its shape — the guard
behaved differently depending on whether jq was installed. The heredoc test
below fails without this on a runner with no jq.

Tests

Six added to .github/scripts/test-hooks.sh, continuing from the existing #5.
Where #5 covers a guarded phrase in another JSON field, these cover it as data
inside the command itself.

PASS: pre-bash-guard allows a grep pattern containing the guarded phrase
PASS: pre-bash-guard allows a commit message documenting the guard
PASS: pre-bash-guard allows a word that merely ends in r-m
PASS: pre-bash-guard allows grepping the docs for a guarded git phrase
PASS: pre-bash-guard allows a commit message that merely names a shell wrapper
PASS: pre-bash-guard allows a heredoc commit message about the guard
PASS: pre-bash-guard still blocks a recursive force delete of a project directory
PASS: pre-bash-guard still blocks a recursive force delete wrapped in bash -c

Five of the six fail against the current guard — I reverted hooks/ and kept
the tests to check. The last two are the guard rails on my own escape hatch.

Separately, a 29-case matrix covering every danger shape the guard recognises —
&&, ;, pipe into xargs, the -fr and -rfv spellings, bash -c
wrapping, absolute paths, and all four git phrases — passes identically before
and after. Nothing that blocked before stops blocking.

Not included

No CHANGELOG.md entry — entries there are grouped under released versions and
there is no Unreleased section, so that seemed yours to place at release time.
Happy to add one if you'd rather.

🤖 Generated with Claude Code

pre-bash-guard.sh matched its guarded phrases anywhere in the command text, so
it blocked commands that read or write *about* the thing it guards:

  grep -n 'Remove-Item\|rm -rf\|BLOCKED' hooks/pre-bash-guard.sh
  grep -rn 'git reset --hard' docs/
  git commit -m "docs: explain why rm -rf is blocked"
  confirm -rf task

None of these delete or discard anything. The first is a command that reads the
guard's own source; the last is any word ending in the letters r and m followed
by a flag. A guard that blocks reading and documenting itself teaches people to
route around it, which is worse than not guarding.

This is the same class of defect codewithmukesh#23 fixed in pre-commit-antipattern.sh — a
naive grep over text that holds both code and prose about code. The same answer
applies here.

Four passes, in order:

1. Heredoc bodies collapse to a placeholder. A commit message written with
   `cat >msg <<'EOF'` is prose, and prose about a guard quotes what it guards.
   Delimiter lines stay, so the shape of the command is still readable.

2. Quoted spans collapse too. That is what separates a commit message carrying
   a phrase from a command that is one, and it reduces a grep alternation,
   escapes and all, to a single harmless token.

3. A shell wrapper hands its quoted argument back to a shell, so when one is
   present the unmasked text is scanned instead. The wrapper is itself looked
   for in the masked text and in command position, so prose that merely names
   one does not switch the scan back on.

4. A verb only counts in command position: the start of the command, or after
   something that begins one. This is what stops "confirm -rf".

The rm allowlist reads the unmasked text, being the one check that needs the
target path, and now also accepts a quoted target — "node_modules" is the same
target as node_modules, and quoting is what anybody does once a path has a
space.

The no-jq fallback now undoes JSON string escapes. Without that a multi-line
command arrives as a single line carrying literal \n, so nothing that reads the
command line by line can see its shape, and the guard behaved differently with
and without jq installed.

Six tests added to .github/scripts/test-hooks.sh, continuing from the existing
number 5. Where 5 covers a guarded phrase in another JSON field, these cover it
as data inside the command itself. Five fail against the current guard. The
last two are the guard rails: a recursive force delete of a project directory,
and one wrapped in a shell wrapper, both still blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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