fix(hooks): stop matching patterns against heredoc prose - #19
Merged
Conversation
Both PreToolUse hooks matched their patterns against the raw command string, so text carried in a heredoc body was read as code. A `gh pr create` whose description quoted tooling after an `&&`, or a destructive command at the start of a line, was denied outright. Found in practice: enforce-uv refused the `gh pr create` that opened the documentation coherence pass, because the PR body quoted a verification command. protect-main had the same latent flaw and had escaped it only because earlier PR bodies happened to put a backtick where the pattern needed whitespace. Both now match against the command with heredoc bodies stripped, via a shared lib. Rewrites still emit the original command, and code before or after a heredoc is unaffected. Adding that lib surfaced a second bug: .gitignore's unanchored `lib/` rule silently excluded it, and would do the same to a nested source directory in any project built from this template. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 bug
Both
PreToolUsehooks matched their patterns against the raw command string. A hook sees the whole string, which mixes code with data — and a heredoc body is data:enforce-uv's pattern for a bare linter invocation is(^|[;&|]\s*)ruff\b. It matched the&&inside the prose and denied the command. That is not a hypothetical: it denied thegh pr createthat opened #18.protect-mainhas the identical flaw — a description containingrm -rf /orgit push --force origin mainat a line start would be blocked. It had escaped notice only by luck: earlier PR bodies wrapped those examples in backticks, and the patterns need whitespace or end-of-line where a backtick sat.This is the class of fault #17 was written to catch — a script that is syntactically clean, correctly wired, and reaching the wrong decision. The existing tests missed it because they all pass single-line, well-formed commands.
The fix
A shared
.claude/hooks/lib/command-text.shstrips heredoc bodies, keeping the line that opens them. Both hooks match against the stripped text;enforce-uv's rewrite still emits the original command, soupdatedInputis unaffected.Handles
<<EOF,<<'EOF',<<"EOF"and the tab-stripping<<-EOF. Code before or after a heredoc is still inspected.Deliberate trade, documented in the file: a heredoc fed to an interpreter (
bash <<EOF) really is executable, and its body is no longer inspected. These hooks guard against slips rather than an adversary, and a false deny costs real work every time it fires, while that bypass costs nothing until someone goes looking for it.Second bug, surfaced by the first
Adding
.claude/hooks/lib/produced no commit —.gitignoreline 17 has an unanchoredlib/from the standard Python packaging block, so it silently excluded the new file. Had I not checkedgit status, this would have shipped hooks thatsourcea file absent from the repository.The same rule affects anyone using this template: an unanchored
lib/swallows a nestedsrc/<pkg>/lib/. Anchoredlib/andlib64/to the repository root, which is where distutils actually writes them.Note that
scripts/validate_config.pywould not have caught this — it verifies hook scripts named insettings.json, and a sourced sibling is invisible to it. Worth considering separately.Tests
tests/unit/test_heredoc_false_positives.py, 10 cases: the exactgh pr createshape that failed, a commit message quoting tooling at line start, every heredoc spelling, and two guard tests asserting that code after a heredoc is still denied.Mutation-checked: pointing the hooks back at the raw command turns 8 of 10 red. The 2 that stay green are the guard tests, which must pass either way.
Full suite is now 75 tests.
Verification
Lint, format, config validation and all 75 tests pass locally. The hooks were also smoke-tested directly: silent on
uv run pytest, denying onpip install x.Not included
scripts/validate_config.pyis modified in my working tree by something other than me — it adds explicit keyword arguments tore.compileand strips the output alignment. It is unrelated to this fix and I have left it uncommitted pending your call.🤖 Generated with Claude Code