Skip to content

Claude code hooks setup - #19

Merged
agrorithms merged 10 commits into
mainfrom
claude-code-hooks-setup
Aug 3, 2026
Merged

Claude code hooks setup#19
agrorithms merged 10 commits into
mainfrom
claude-code-hooks-setup

Conversation

@agrorithms

Copy link
Copy Markdown
Owner

No description provided.

strip_heredocs() treated a `<<` inside a quoted string as a heredoc opener,
adopted a terminator that never arrived, and discarded every following line.
Any command whose real invocation sat on a later line was never seen:

  echo "a << B"
  rm -rf node_modules   <- allowed, silently

Detection now runs on a probe copy of each line: heredoc terminators are
unquoted first (blanking quotes without this eats `<<'EOF'` and breaks real
heredoc stripping), then remaining quoted spans are blanked, then the opener
is matched. The original line is what gets emitted.

Fixing it inside strip_heredocs rather than by reordering its call site keeps
it correct for callers that do not blank quotes — which guard-destructive.sh
is about to stop doing.

Also points the test suite at a throwaway HOOK_STATE_DIR. It rm -rf's its
state dir repeatedly; against the live path that deletes the warned-PID file
of any running session, silently disabling its orphan check.

55 assertions pass.

Claude-Session: https://claude.ai/code/session_01FzB5J887WkGH3CPYJ3ekxN
…paths

The spec's matching policy ("a false positive costs more than a bypass") was
derived from R1/R2, where the decision is DENY and a false positive blocks
real work. R7 emits ASK, so it inverts: a false prompt costs one keystroke, a
miss costs node_modules, data/ or .env. Addresses review findings P-3 and P-5.

invokes_strict() drops quote-blanking and accepts sh -c / bash -c wrappers, so
`sh -c "rm -rf x"` is now gated. The pinned §A3 bypass stays for R1/R2 — they
still blank quotes, so the wrapper would have nothing to match against there.

Newly gated, from auditing what these actually do in this repo:
  git clean       - `git clean -fdx` removes data/ (9.0G), .env,
                    .claude/plans/, certificates/, docs/handoffs/. None of it
                    in git. It was ungated while `rm -rf` was gated.
                    `-n`/`--dry-run` stays silent.
  git reset --hard, git checkout --, git restore
                  - destroy uncommitted work, routinely the only copy here.
                    Plain `git checkout <branch>` is not matched.

R7.3, the untracked-path rule: any rm naming a gitignored path asks whatever
its flags. `rm data/raid-tracker.db` needs no -r — it is a file — so the flag
rule never saw it. Resolved via `git check-ignore` rather than a hardcoded
list: of 20 gitignored entries only ~5 are regenerable, and a hand-written
list was already missing docs/handoffs/, certificates/, scripts/cleanup/ and
seeds.txt when first drafted.

Consequence worth knowing: CLAUDE.md's restore step
`rm data/raid-tracker.db-wal` now prompts. That is intended.

76 assertions pass.

Claude-Session: https://claude.ai/code/session_01FzB5J887WkGH3CPYJ3ekxN
The Stop hook decided "this session started it" by diffing against a port
snapshot written at SessionStart. That baseline broke on /clear and resume:
both are SessionEnd reasons, so re-entry deleted the state and every port
holder then read as session debris — complete with a ready-to-run kill aimed
at what may be the user's own server. Review finding P-1, reproduced:

  /clear   snapshot [12345]->[]  warned [99999]->[]

Fork had no answer at all: it assigns a fresh session_id, so no keying scheme
carried the parent's baseline.

Replaced with a comparison that needs no state: a port holder younger than the
claude process is one this session started. That deletes the baseline file, the
SessionStart hook, the SessionEnd hook, the write-if-absent dance, and the
mtime-lockstep touch that kept two files aging as a unit. Findings P-1, P-2 and
P-4 dissolve rather than being patched. Hook surface goes from six to four.

session_age() walks UP the process tree looking for `claude`. It must not
shortcut to $PPID — a hook's parent is a per-invocation `/bin/sh -c` wrapper
whose own age is always 0. Instrumenting a live hook showed:

  10987  1464  0     /bin/sh -c ".../guard-build.sh"
   1464    10  2778  claude

Reading that 0 would have classified every holder as older than the session, so
nothing would ever be reported and the check would have been silently dead.

classify_holders() is kept pure, with the age passed in, so fail-open can be
tested: an empty or non-numeric age reports nothing (R4.4). That case cannot be
staged end-to-end — every process a test spawns is a descendant of the real
claude, so "no ancestor" is unreachable from inside a session.

Accepted, and unchanged from the snapshot design: a server the user starts
mid-session in another terminal is younger than the session and gets reported.
The message says "started after this session began" rather than claiming
authorship.

Also adds the first-ever test of the build guard's detection path, which had
only ever been asserted in the negative.

87 assertions pass.

Claude-Session: https://claude.ai/code/session_01FzB5J887WkGH3CPYJ3ekxN
Spec, in place rather than superseded:
  R3   removed - the session-start snapshot, with why the baseline was the
       wrong primitive and why R3.1's rationale was circular
  R4.2 rewritten around process age; records the accepted misreport case
  R4.4 same fail-open guarantee, new mechanism, and why it cannot be tested
       end-to-end (every test process descends from the real claude)
  R5.3 added - state dir must be overridable; the suite was deleting live state
  R6.1 removed with the SessionEnd hook; R6.2's sweep relocated to Stop
  R7.2 extended to git clean / reset --hard / checkout -- / restore
  R7.3 added - untracked-path rule, and why check-ignore beats a fixed list
  R7.4 added - R7 matches with invokes_strict()

  Matching policy scoped to R1/R2, with the R7 inversion recorded as finding
  P-3 rather than left implicit. Non-goals: fork gap closed, sh -c narrowed to
  R1/R2 with the reason it is forced there, dispatcher merge rejected with the
  measurement behind it.

  Two constraints now carry evidence instead of assertion: the real build
  cmdline (verified against an actual build, with the full process list), and
  that a hook's $PPID is a sh -c wrapper with etimes=0, not claude. The second
  is load-bearing for R4.2 and would have silently disabled the check.

decisions.md gains a 2026-08-03 entry that supersedes the two baseline
sections above it.

87 assertions pass. npm run build exits 0.

Claude-Session: https://claude.ai/code/session_01FzB5J887WkGH3CPYJ3ekxN
…ming

Both axes of the post-implementation review independently found the same
severity-1 bug, introduced in be5bda9 and reproduced by hand before fixing:

  git clean -fdx                     -> ask
  git clean -fdx && head -n 20 file  -> ALLOWED   (wrong)
  grep -n foo x && git clean -fdx    -> ALLOWED   (wrong)

The dry-run exemption grepped the WHOLE command for any -flag containing an
'n', then exited the hook. So an unrelated head -n / sort -n / grep -n anywhere
un-gated a real destructive clean, and because it exited rather than skipping
one rule, it also disabled the rm -rf and untracked-path rules for that call —
precisely the "a miss costs data/" case the strictness decision was made to
avoid. Everything the file does to be command-position-aware, this one line
threw away.

git clean now has its own predicate, evaluated per clause: a clean is exempt
only when the clause invoking it carries the dry-run flag itself. Pinned by
regression tests at both the predicate and hook level. Recorded as R7.2b.

Also from the review:
  - HOOK_DESTRUCTIVE moved into lib.sh. The tests held two copies of the
    pattern and one had already drifted to a narrower version than production
    used, so the C2 assertions were testing a pattern nothing ran. Same
    reasoning already applied to HOOK_WRAPPERS.
  - classify_holders -> holders_younger_than. It filters rather than
    classifies; the name should say what comes back.
  - rm_touches_ignored uses `local -` so globbing is restored on any exit path
    rather than being force-enabled.
  - One EXIT trap via a cleanup(), not two where the second silently replaced
    the first.
  - Tests restore DEV_PORT=3000 instead of unsetting it; lib.sh resolves it at
    source time, so unset left an empty port for anything running afterwards.
  - Stale spec heading and function name corrected.

95 assertions pass. Dry runs stay silent; no new noise on head -n / grep -n.

Claude-Session: https://claude.ai/code/session_01FzB5J887WkGH3CPYJ3ekxN
@agrorithms
agrorithms merged commit aed7f7f into main Aug 3, 2026
2 checks passed
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