Skip to content

fix(hooks): stop matching patterns against heredoc prose - #19

Merged
skateddu merged 1 commit into
mainfrom
fix/hook-false-positive-on-heredoc
Aug 13, 2026
Merged

fix(hooks): stop matching patterns against heredoc prose#19
skateddu merged 1 commit into
mainfrom
fix/hook-false-positive-on-heredoc

Conversation

@skateddu

Copy link
Copy Markdown
Owner

Stacked on #18 — base is docs/coherence-pass. Merge #18 first and GitHub will retarget this to main.

The bug

Both PreToolUse hooks 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:

gh pr create --body "$(cat <<'EOF'
Run the checks locally with:

    uv run ruff check . && ruff format --check . && pytest
EOF
)"

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 the gh pr create that opened #18.

protect-main has the identical flaw — a description containing rm -rf / or git push --force origin main at 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.sh strips heredoc bodies, keeping the line that opens them. Both hooks match against the stripped text; enforce-uv's rewrite still emits the original command, so updatedInput is 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 — .gitignore line 17 has an unanchored lib/ from the standard Python packaging block, so it silently excluded the new file. Had I not checked git status, this would have shipped hooks that source a file absent from the repository.

The same rule affects anyone using this template: an unanchored lib/ swallows a nested src/<pkg>/lib/. Anchored lib/ and lib64/ to the repository root, which is where distutils actually writes them.

Note that scripts/validate_config.py would not have caught this — it verifies hook scripts named in settings.json, and a sourced sibling is invisible to it. Worth considering separately.

Tests

tests/unit/test_heredoc_false_positives.py, 10 cases: the exact gh pr create shape 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 on pip install x.

Not included

scripts/validate_config.py is modified in my working tree by something other than me — it adds explicit keyword arguments to re.compile and strips the output alignment. It is unrelated to this fix and I have left it uncommitted pending your call.

🤖 Generated with Claude Code

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>
@skateddu
skateddu deleted the branch main August 13, 2026 13:56
@skateddu skateddu closed this Aug 13, 2026
@skateddu skateddu reopened this Aug 13, 2026
@skateddu
skateddu changed the base branch from docs/coherence-pass to main August 13, 2026 13:58
@skateddu
skateddu merged commit dfb3b99 into main Aug 13, 2026
2 checks passed
@skateddu
skateddu deleted the fix/hook-false-positive-on-heredoc branch August 13, 2026 13:59
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