Skip to content

fix(hooks): three silent-failure bugs found during hook audit - #72

Merged
5uck1ess merged 2 commits into
mainfrom
fix/rtk-rewrite-exit-protocol
Apr 11, 2026
Merged

fix(hooks): three silent-failure bugs found during hook audit#72
5uck1ess merged 2 commits into
mainfrom
fix/rtk-rewrite-exit-protocol

Conversation

@5uck1ess

Copy link
Copy Markdown
Owner

Summary

Three silent-failure bugs in the devkit hook stack that were all producing wrong behavior without any visible error. Found while diagnosing why rtk rewrites never seemed to land.

1. rtk-rewrite.sh — every rewrite silently discarded

The hook was written against rtk's old contract (|| exit 0 from rtk rewrite --help), but the binary has since evolved to a 4-value protocol that's embedded in the binary but not yet in --help:

0 + stdout   rewrite found, auto-allow
1            no RTK equivalent → pass through
2            deny rule matched → pass through (CC native deny handles it)
3 + stdout   ask rule matched → rewrite with permissionDecision=ask

Default rtk rules match ls, git, find, … with ask, so the binary was correctly producing rewrites and exiting 3, and the hook's || exit 0 was discarding every single one. Input-side token savings were effectively dead.

New hook handles all four codes and maps rtk's ask signal to Claude Code's permissionDecision: "ask".

2. security-patterns.sh — per-session dedup silently disabled

SEEN_FILE="/tmp/devkit-security-seen-$$" used $$, which is the hook's own bash PID — a brand-new process every invocation. So the "warn once per file+pattern per session" behavior was broken: every save wrote to a unique dedup file and re-fired the same warning. Fixed by switching to $PPID (the Claude Code process that spawned the hook, stable per session).

3. post-validate.sh — false "outside repo" warning on every macOS save

realpath -m is a GNU coreutils flag — macOS BSD realpath doesn't have it. The || echo "$FILE_PATH" fallback kept relative paths unresolved, so src/foo.go failed the "$REPO_ROOT"/* glob match and generated noise warnings on every Edit/Write. Replaced with a portable shell-only absolute-path conversion, and also whitelisted /var/folders/* (macOS TMPDIR) alongside /tmp.

Test plan

  • 8 new test cases added to hooks/hooks_test.sh:
    • rtk-rewrite: all four exit codes (0/1/2/3) via a PATH shim so tests are deterministic regardless of installed rtk version
    • rtk-rewrite: empty command no-op
    • security-patterns: dedup suppresses repeat warning on second call (with a note on the bash $() subshell PPID gotcha so the test topology matches production)
    • post-validate: relative in-repo path produces no warning (pins the realpath fix on macOS)
    • post-validate: absolute out-of-repo path correctly warns
  • Full bash hooks/hooks_test.sh suite: 60 passed, 0 failed (was 52 before)
  • go test ./... in src/: all packages green
  • Manually verified against real rtk 0.35.0 on macOS: ask decisions now produce rewritten commands with permissionDecision: "ask" as intended

rtk-rewrite: update to rtk's 4-value exit code protocol. The binary
embeds the protocol (0 allow, 1 no-equiv, 2 deny, 3 ask) but its own
--help text still documents the old `|| exit 0` contract, so the hook
was treating exit 3 ("rewrite available, ask user") as failure and
silently discarding every rewrite. Default `rtk` rules match common
commands (ls, git, find, ...) with ask, so input-side token savings
were effectively dead. New hook handles each exit code correctly and
maps rtk's ask decision to Claude Code's `permissionDecision: "ask"`.

security-patterns: fix $$ → $PPID for the per-session dedup file. $$
is the hook's own bash PID, which is brand new for every invocation,
so the "warn once per file+pattern per session" behavior was silently
disabled — every save wrote to a unique dedup file and re-fired the
same warning. $PPID is the Claude Code process that spawned the hook,
which is stable for the session.

post-validate: replace GNU-only `realpath -m` with portable absolute-
path conversion. On macOS BSD realpath has no -m flag, so the previous
`|| echo "$FILE_PATH"` fallback left relative paths unresolved, and
every benign `src/foo.go` save wrongly matched the "outside repo"
branch and emitted a noise warning. Also whitelists macOS's
/var/folders/* TMPDIR alongside /tmp.

Tests: 8 new cases covering (1) all four rtk exit codes via a PATH
shim so the tests are deterministic regardless of the installed rtk
version, (2) security-patterns dedup across two real invocations
(with a note on the bash $() subshell PPID gotcha so the test
topology matches production), (3) post-validate relative-in-repo
and absolute-out-of-repo path handling.

All 60 hook smoke tests pass; Go engine tests unchanged.
Two follow-ups on the rtk-rewrite hook based on PR #72 review and
user feedback:

1. Collapse rtk exit 3 → "allow" instead of "ask". rtk's default
   rules match common commands (ls, git, find, …) with ask, so the
   previous behavior prompted on every Bash call. devkit's own
   safety-check.sh already fires earlier in the PreToolUse chain for
   destructive operations, so a second rtk-side prompt is redundant
   noise. Both rc 0 and rc 3 now auto-apply the rewrite.

2. Guard jq calls with `|| true`. Under `set -euo pipefail`, a jq
   failure on malformed stdin (or any CC protocol hiccup) would
   propagate non-zero out of the command substitution and CC would
   treat the hook as blocked. The hook now degrades to empty
   $COMMAND and exits 0 cleanly on bad input, matching pre-PR
   silent no-op behavior.

Tests: 4 new cases pin the new contract and edge coverage:
 - rc 0 with empty stdout → no-op (pins the empty-rewrite guard)
 - rc unknown (42) → pass through (fail-open)
 - rewrite equal to input → no-op (pins the equality guard)
 - malformed JSON stdin → exit 0 silent no-op (regression test)

Existing rc 3 test updated to assert decision="allow" instead of
"ask". 64 hook tests pass, 0 failures.
@5uck1ess
5uck1ess merged commit 4036d76 into main Apr 11, 2026
6 checks passed
@5uck1ess
5uck1ess deleted the fix/rtk-rewrite-exit-protocol branch April 11, 2026 06:38
5uck1ess added a commit that referenced this pull request Apr 11, 2026
Three related fixes from the #72 review audit, bundled because the
tests cluster in the same file.

#73 — hooks_test.sh run_hook silently swallowed exit codes. `OUTPUT=$(…)
|| true` followed by `EXIT=$?` always saw true's rc=0, so every
run_hook caller's exit-code assertion was dead. The suite runs under
`set -uo pipefail` (no -e), so the `|| true` was unnecessary; drop it
and `$?` now carries the hook's real exit.

#74 — post-validate.sh path handling edge cases. Normalize both
REPO_ROOT and ABS_PATH through a portable realpath path (python3 with
a dirname/pwd fallback) so that: (a) `..` escapes from the repo root
no longer match the "$REPO_ROOT"/* glob, (b) symlinked repo roots
compare consistently, (c) cwd=subdir + ../in-repo paths still resolve
inside the repo. Extend the TMPDIR allowlist to include
/private/var/folders/* since realpath resolves /var → /private/var on
macOS. New tests pin all four scenarios (subdir cwd, .. escape,
TMPDIR, symlinked root).

#75 — three cleanup items:
  1. security-patterns.sh: add `set -euo pipefail` with the same
     jq-tolerance guards as rtk-rewrite.sh (post-validate.sh already
     had it).
  2. Per-pattern dedup test: assert that a second, different pattern
     on the same file still warns, guarding against a key collapse
     that would flatten the dedup to warn-once-ever.
  3. rtk-rewrite rc 3 + rewrite == input test: new shim mode exercises
     the intersection of the ask-suppression branch and the identity
     guard.
  4. Comment trim: replace "pins the $$→$PPID fix" / "Pins the
     realpath -m GNU-ism fix…" archaeology with invariant phrasing.

Results: 70 passed, 0 failed (was 66 passed, 0 failed).
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