Skip to content

fix(hooks): escape cmd_* answers for the quoting context they land in - #99

Open
tigers1997 wants to merge 1 commit into
mainfrom
fix/stop-hook-command-quoting
Open

tigers1997 wants to merge 1 commit into
mainfrom
fix/stop-hook-command-quoting

Conversation

@tigers1997

Copy link
Copy Markdown
Owner

Reported downstream: a cmd_lint containing a double quote produced a Stop hook that was dead on arrival.

cmd_lint = python -c "import ast,io,glob; [ast.parse(io.open(f).read()) for f in glob.glob('*.py')]"

interpolated raw into the "label|<command>" bash array element:

line 60: syntax error near unexpected token `;'
line 62: syntax error near unexpected token `)'

The quote closes the element early and the whole CHECKS=(...) array stops parsing. The hook then fails at load, emits no report, and leaves a check loop that looks installed — the worst failure mode for a Stop hook, because nothing surfaces it.

The quieter half

A command with an embedded quote but no parentheses parses without error and silently splits into several array elements, so the check runs truncated and nothing complains at all. Same root cause, no diagnostic.

The .ps1 variant had the bug mirrored — a single quote breaks the single-quoted PowerShell hashtable value in stop-run-checks.ps1 — but nobody had hit it yet.

Fix

substitute_placeholders() now takes an escaper selected per target extension:

target context escaped
.sh double-quoted bash string \ " $ backtick
.ps1 single-quoted PowerShell string ' doubled
everything else prose / JSON nothing — raw answer

Prose targets keep the raw value, so escaping cannot leak into CLAUDE.md.

Escaping applies at interpolation only. eval "$run_cmd" still expands $VAR and $(...) in the command at run time, exactly as before — a command that wants shell expansion still gets it.

Test

test/stop-run-checks/test-command-quoting.sh, picked up by the existing test/stop-run-checks/test-*.sh glob in check.yml.

Scaffolds with commands carrying ", ', $, backtick, \, parens and a semicolon, then asserts:

  1. both hooks parse (bash -n; Parser::ParseFile for the .ps1)
  2. all three commands round-trip byte-exact out of the rendered literal — escaping that mangles the command is no better than escaping that breaks the parse
  3. CLAUDE.md still receives the raw command, pinning the per-extension behavior

Reverting the fix reproduces the reported syntax error verbatim. The PowerShell probe skips cleanly where no PowerShell is on PATH (pwsh ships on all three runner images).

Verification

Full suite, working tree vs. a clean HEAD worktree:

baseline (HEAD): 84 pass / 29 fail
working tree:    85 pass / 29 fail
delta: + PASS test/stop-run-checks/test-command-quoting.sh

Identical otherwise — no test changed status. The 29 failures are pre-existing environment failures on this Windows box (Docker-dependent container checks, cygwin fork errors) and reproduce on HEAD. configure.py --check passes; no drift in templates/ or examples/.

Note for anyone already scaffolded

The retrofit path is non-destructive by design, so a re-run stages the corrected stop-run-checks.sh to .claude-retrofit/incoming/ and leaves the broken file live. Promote it, or re-run with --on-collision=overwrite, to actually pick up the fix — then bash -n .claude/hooks/stop-run-checks.sh to confirm.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ke9nVhgkkvmXF55iv5nopf

A check command containing a double quote was interpolated raw into the
generated Stop hook's "label|<command>" bash array element. The quote
closed the element early and the whole CHECKS=(...) array stopped
parsing, so the hook died at load with a syntax error, emitted no
report, and left a check loop that looks installed. Reported from a
downstream project with:

  cmd_lint = python -c "import ast,io,glob; [ast.parse(...)]"

Silent failure is the worst outcome for a check loop, and the quieter
half of the same bug is worse still: a command with an embedded quote
but no parentheses parses without error and splits into several array
elements, so the check runs truncated and nothing complains. The .ps1
variant had the bug mirrored — a single quote breaks the single-quoted
PowerShell hashtable value — but nobody had hit it yet.

substitute_placeholders() now takes an escaper chosen per target
extension: backslash, ", $ and backtick for the double-quoted bash
literal in stop-run-checks.sh; ' doubled for the single-quoted
PowerShell literal in stop-run-checks.ps1. Prose targets (CLAUDE.md)
keep the raw answer, so the escaping cannot leak into documentation.

Escaping applies at interpolation only: eval "$run_cmd" still expands
$VAR and $(...) in the command at run time, as it always did.

test-command-quoting.sh scaffolds with a command carrying ", ', $,
backtick, backslash, parens and a semicolon, then asserts both hooks
parse and that all three commands round-trip byte-exact out of the
rendered literal — escaping that mangles the command is no better than
escaping that breaks the parse. Reverting the fix reproduces the
reported syntax error verbatim. The PowerShell probe skips cleanly
where no PowerShell is on PATH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke9nVhgkkvmXF55iv5nopf
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