From 7b05e46a753fac969a13b7499bd6f8398c5dcdbf Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Fri, 24 Jul 2026 10:08:42 -0500 Subject: [PATCH 1/5] fix(hooks): push-block matcher precision + jq-free fallback + visible degradation (U1) pre-push-main-blocker.sh compared main/master as a substring anywhere in the push command, so branch names like feature/main-cleanup or domain-master-list were wrongly denied (a live, reproduced false positive). Rewrite the check to compare the actual destination-branch token for equality instead, and extract tool_name/command via a jq-free, field-scoped sed idiom (generalized from branch-pr-discipline.sh's, ported to sed -E for BSD/GNU portability - the literal BRE alternation form silently extracts nothing on BSD/macOS sed). Dropping jq entirely for this hook also closes the prior gap where a missing jq skipped it altogether, including the bare/implicit push-while-on-main case permissions.deny has no literal branch name to pattern-match against. session-start-loader.sh now checks for jq before anything else and, when absent, emits a [HOOK DEGRADATION] context block naming exactly what's degraded (secret detection + file locks, dangerous-command warnings, pre-commit reminders) instead of failing open silently - permissions.deny and the branch-block are both unaffected regardless. docs/hooks.md gains a consolidated Degradation visibility subsection; MIGRATION.md documents the new session-start output. Co-Authored-By: Claude Fable 5 --- .claude/hooks/pre-push-main-blocker.sh | 102 +++++++++++++--------- .claude/hooks/session-start-loader.sh | 22 ++++- CHANGELOG.md | 1 + MIGRATION.md | 10 +++ docs/hooks.md | 10 +++ scripts/hook-tests.d/10-hooks-lane.sh | 113 +++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 43 deletions(-) create mode 100755 scripts/hook-tests.d/10-hooks-lane.sh diff --git a/.claude/hooks/pre-push-main-blocker.sh b/.claude/hooks/pre-push-main-blocker.sh index ed67d3e..e265414 100755 --- a/.claude/hooks/pre-push-main-blocker.sh +++ b/.claude/hooks/pre-push-main-blocker.sh @@ -7,71 +7,93 @@ # - Commits on main: ALLOWED (may commit) # - Push to non-main branches: ALLOWED # - Push to main: BLOCKED +# +# No jq dependency: the two fields this hook reads (.tool_name, +# .tool_input.command) are both flat JSON strings, so the field-scoped sed +# idiom used by branch-pr-discipline.sh extracts them precisely — never by +# substring-matching the raw JSON blob. This also closes the old fail-open +# gap where an absent jq skipped the whole hook, including the implicit +# "bare `git push` while on main" case that permissions.deny cannot express +# (it has no literal branch name in a bare push to pattern-match against). -INPUT=$(cat) +payload="$(cat 2>/dev/null || true)" +[ -z "$payload" ] && exit 0 -# jq is required to parse tool input; fail open if unavailable (hooks are -# guardrails, not a security boundary) -command -v jq >/dev/null 2>&1 || exit 0 +# extract_json_field: prints the string value of a flat "field":"value" pair +# in $payload (e.g. tool_name, or tool_input's leaf key "command"), or empty +# if absent. Handles JSON-escaped characters inside the value via the +# (...|\\.) alternation — same field-scoped idea as branch-pr-discipline.sh's +# command extraction, generalized to a field name and written with `sed -E` +# (extended regex) rather than backslash-escaped BRE groups/alternation: +# BSD/macOS sed's BRE mode does not support `\|` as alternation (it free +# passes as a literal pipe), so the exact BRE form silently extracts nothing +# there — `-E` is portable across both BSD and GNU sed. +extract_json_field() { # $1=field name + printf '%s' "$payload" | sed -nE 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)".*/\1/p' | head -n1 +} -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true) -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true) +TOOL_NAME=$(extract_json_field tool_name) +COMMAND=$(extract_json_field command) # Only process Bash tool -if [ "$TOOL_NAME" != "Bash" ]; then - exit 0 -fi +[ "$TOOL_NAME" != "Bash" ] && exit 0 # Only check git push commands -if ! echo "$COMMAND" | grep -qE '\bgit\s+push\b'; then - exit 0 -fi - -# Get current branch -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") +echo "$COMMAND" | grep -qE '\bgit\s+push\b' || exit 0 -# Check if pushing to main -# Patterns to detect: -# - git push (on main branch, pushes to upstream main) -# - git push origin main -# - git push origin main:main -# - git push -u origin main -# - git push --set-upstream origin main +# Current branch — -C defaults to "." (a real invocation's cwd is already the +# project dir); the explicit path lets tests point this at a throwaway +# fixture repo without changing this process's own cwd. +CURRENT_BRANCH=$(git -C "${CLAUDE_PROJECT_DIR:-.}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") IS_PUSH_TO_MAIN=false +DENY_REASON="" -# Check for explicit main/master in push command -if echo "$COMMAND" | grep -qE '\bgit\s+push\b.*\b(main|master)\b'; then - IS_PUSH_TO_MAIN=true -fi +# Trailing positional token after `git push` — the destination-branch +# position in ordinary `git push [opts] [remote [refspec]]` usage — compared +# for EQUALITY, never substring/word-boundary, against main/master. A +# word-boundary match (the previous approach) also matches "main" inside +# branch names like `feature/main-cleanup` or `domain-master-list`, which is +# the reproduced false positive this rewrite fixes. +push_args="${COMMAND#*git push}" +read -ra _tokens <<< "$push_args" +positional=() +for tok in "${_tokens[@]}"; do + case "$tok" in + -*) continue ;; + *) positional+=("$tok") ;; + esac +done -# Check for push without explicit branch while on main -# This catches: git push, git push origin, git push -u origin -if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then - # If no branch specified in push command, it will push current branch - if ! echo "$COMMAND" | grep -qE '\bgit\s+push\b.*\s+[a-zA-Z0-9_-]+\s+[a-zA-Z0-9_/-]+'; then - # No explicit remote/branch pair - will push current branch - # Check if it's just "git push" or "git push origin" without branch - if echo "$COMMAND" | grep -qE '\bgit\s+push\s*$' || \ - echo "$COMMAND" | grep -qE '\bgit\s+push\s+(--[a-z-]+\s+)*[a-zA-Z0-9_-]+\s*$'; then - IS_PUSH_TO_MAIN=true - fi +if [ "${#positional[@]}" -ge 2 ]; then + # Explicit remote + ref given: the ref is the destination. A `src:dst` + # refspec names the destination after the colon. + dest="${positional[${#positional[@]}-1]}" + case "$dest" in + *:*) dest="${dest##*:}" ;; + esac + if [ "$dest" = "main" ] || [ "$dest" = "master" ]; then + IS_PUSH_TO_MAIN=true + DENY_REASON="explicit push to $dest" fi +elif [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then + # Bare/implicit push (no explicit remote+ref pair): git pushes the + # current branch via its configured upstream (or push.default=simple). + IS_PUSH_TO_MAIN=true + DENY_REASON="implicit push of current branch '$CURRENT_BRANCH'" fi -# Block if pushing to main if [ "$IS_PUSH_TO_MAIN" = true ]; then cat << EOF { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", - "permissionDecisionReason": "BLOCKED: Cannot push directly to main branch. Trunk-based development requires:\\n\\n1. Create a feature branch: git checkout -b feature/your-change\\n2. Commit your changes on the branch\\n3. Push the branch: git push -u origin feature/your-change\\n4. Create a PR for review\\n\\nCurrent branch: $CURRENT_BRANCH" + "permissionDecisionReason": "BLOCKED: Cannot push directly to main branch ($DENY_REASON). Trunk-based development requires:\\n\\n1. Create a feature branch: git checkout -b feature/your-change\\n2. Commit your changes on the branch\\n3. Push the branch: git push -u origin feature/your-change\\n4. Create a PR for review\\n\\nCurrent branch: $CURRENT_BRANCH" } } EOF exit 0 fi -# Allow all other push commands exit 0 diff --git a/.claude/hooks/session-start-loader.sh b/.claude/hooks/session-start-loader.sh index ea05ecc..69e3683 100755 --- a/.claude/hooks/session-start-loader.sh +++ b/.claude/hooks/session-start-loader.sh @@ -4,9 +4,25 @@ INPUT=$(cat) -# jq is required to parse tool input; fail open if unavailable (hooks are -# guardrails, not a security boundary) -command -v jq >/dev/null 2>&1 || exit 0 +# jq is required to parse tool input past this point. Rather than failing +# silently when it's missing (hooks are guardrails, not a security boundary +# — but a silent gap is worse than a visible one), surface exactly what +# degrades so the current session knows what it can't rely on. +if ! command -v jq >/dev/null 2>&1; then + cat << 'EOF' + +[HOOK DEGRADATION] +jq is not installed — the following guardrails are degraded for this session: +- Secret detection & file-lock coordination (pre-tool-use-validator.sh): skipped entirely +- Dangerous-command warnings (dangerous-command-guard.sh): skipped entirely +- Pre-commit verification reminders (pre-commit-verification.sh): skipped entirely +Unaffected: pre-push-main-blocker.sh's branch-block does not depend on jq and +keeps working either way; permissions.deny (.claude/settings.json) is +enforced at the permission layer regardless of jq or any hook. +Install jq to restore full hook coverage. +EOF + exit 0 +fi SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup") SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || true) diff --git a/CHANGELOG.md b/CHANGELOG.md index 410ac25..7c9d091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `rules-lines` CI invariant (check #22, `scripts/check-invariants.sh`): sums `.claude/rules/*.md` line counts, excluding any file whose frontmatter carries a `paths:` key (load-on-demand, not always-loaded), against a 500-line budget — measured 409 lines at implementation time. `docs/customization.md`'s "Adding a Rule" section gains a three-tier table (always-loaded rule / `paths:`-scoped rule / skill) documenting the budget and native `paths:` frontmatter mechanism (unit U8) - `.claude/rules/hooks-conventions.md`: the framework's first `paths:`-scoped rule (`.claude/hooks/**`, `scripts/**`), dogfooding the tier documented in U8 — shell baseline (`set -u`, shellcheck/`bash -n`, ≤~120 LOC), the fail-open-visibly pattern, the field-scoped `sed` stdin-extraction idiom (citing `branch-pr-discipline.sh`), and the deny/ask JSON output contract. Excluded from `rules-lines`' budget by design (verified: 409 lines counted with the `paths:` frontmatter intact vs. 473 if it were stripped). `docs/customization.md` links it as the tier table's worked example and notes stack packs may use the same mechanism (unit U12) - REVIEW.md freshness contract: `review-steering/SKILL.md`'s generation workflow gains step 8, stamping REVIEW.md's final line with `` (`cat .claude/rules/code-quality.md .claude/rules/security.md | shasum -a 256 | cut -d' ' -f1`); its Refresh Discipline section now cites the mechanical check instead of an unenforced "must never contradict" assertion. New `review-freshness` CI invariant (check #23, `scripts/check-invariants.sh`) recomputes and compares the hash for a tracked root `REVIEW.md`, failing on a missing or stale footer; skips cleanly (this repo ships no REVIEW.md today) when none is tracked (unit U6) +- `pre-push-main-blocker.sh` (`artifacts/plan_framework_hardening.md` unit U1): fixes a live false positive where branch names merely containing `main`/`master` as a substring (e.g. `feature/main-cleanup`, `domain-master-list`) were wrongly denied — the destination-branch token is now compared for equality, not word-boundary substring match. Command extraction is now jq-free (the repo's field-scoped sed idiom, generalized and ported to `sed -E` for BSD/GNU portability — the literal BRE form silently extracts nothing on BSD/macOS sed), which closes the previous gap where a missing `jq` skipped this hook entirely, including the bare/implicit `git push`-while-on-main case that `permissions.deny` can't express. `session-start-loader.sh` now emits a `[HOOK DEGRADATION]` context block naming exactly what's degraded when `jq` is absent instead of failing open silently; `docs/hooks.md` gains a consolidated Degradation visibility subsection ## [4.0.0] - 2026-07-23 diff --git a/MIGRATION.md b/MIGRATION.md index 46bd3a3..f11c10a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -258,4 +258,14 @@ skill carrying all three templates byte-identical — update any references to t and triggers unchanged). If your own agents preloaded `designing-apis` or `application-security` via `skills:` frontmatter, drop those entries — CI's `preload-ungated` check fails on unknown names. +## v4.0.x → next release + +### Session start now surfaces hook-degradation context + +**Who is affected:** anyone whose environment is missing `jq`. Everyone else sees no change. + +**What breaks:** nothing — this is new, additive `SessionStart` output, not a behavior change to any tool call. + +**Action required:** none. If you see a `[HOOK DEGRADATION]` block at session start, it means `jq` isn't on `PATH` in that environment; install `jq` to restore full hook coverage. `permissions.deny` enforcement is unaffected either way — it never depended on hooks or `jq`. + diff --git a/docs/hooks.md b/docs/hooks.md index 4c74b8d..9df01b4 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -158,6 +158,16 @@ prose rules (advisory) < skills (on-demand advisory) < hooks (deterministic guar See `.claude/rules/security.md` for the full ladder and rationale. In short: nothing below `permissions.deny` and CI is guaranteed to run, and hooks specifically are guaranteed to skip rather than block when they can't parse their input. +### Degradation visibility + +"Fails open" used to also mean "fails silently." `session-start-loader.sh` now checks for `jq` before anything else and, when it's absent, prints a `[HOOK DEGRADATION]` block at session start naming exactly what's degraded instead of just skipping quietly: + +- Secret detection & file-lock coordination (`pre-tool-use-validator.sh`) +- Dangerous-command warnings (`dangerous-command-guard.sh`) +- Pre-commit verification reminders (`pre-commit-verification.sh`) + +`pre-push-main-blocker.sh`'s branch-block is **not** on that list: its command extraction is a jq-free sed idiom, so it keeps enforcing with or without `jq`. And `permissions.deny` is unaffected either way — it's enforced at the permission layer, independent of hooks or `jq` entirely. Install `jq` to restore the degraded set above. + **How to disable a hook:** - Remove its entry from `.claude/settings.json` (`hooks` section) to disable it for everyone who pulls that config. diff --git a/scripts/hook-tests.d/10-hooks-lane.sh b/scripts/hook-tests.d/10-hooks-lane.sh new file mode 100755 index 0000000..8a04f86 --- /dev/null +++ b/scripts/hook-tests.d/10-hooks-lane.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# scripts/hook-tests.d/10-hooks-lane.sh — PR1 hooks-lane cases: U1, U9, U2, U4 +# of artifacts/plan_framework_hardening.md. Extends the harness per its own +# open/closed contract (a new numbered file, never edits to +# scripts/test-hooks.sh or 00-baseline.sh) so this lane stays independently +# mergeable. Reuses 00-baseline.sh's `_fresh_dir` helper (that file sources +# before this one, so the function is already in scope) and the runner's +# `run_case` / `path_without_jq`. + +# ============================================================================ +# U1 — push-block matcher precision + jq-free fallback + visible degradation +# ============================================================================ + +# --- false positives fixed: branch names merely containing "main"/"master" +# as a substring must be ALLOWED, not denied -------------------------------- +run_case \ + "pre-push-main-blocker: 'git push origin feature/main-cleanup' allowed (false positive fixed)" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git push origin feature/main-cleanup"}} +JSON +)" \ + "" \ + "exit0-silent" + +run_case \ + "pre-push-main-blocker: 'git push origin domain-master-list' allowed (false positive fixed)" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git push origin domain-master-list"}} +JSON +)" \ + "" \ + "exit0-silent" + +# --- explicit push to main: denied, with jq present AND with jq absent — +# the hook no longer calls jq at all (field-scoped sed extraction instead), +# so both must behave identically ------------------------------------------ +u1_nojq_path=$(path_without_jq) + +run_case \ + "pre-push-main-blocker: explicit 'git push origin main' denied (jq present)" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git push origin main"}} +JSON +)" \ + "" \ + "deny-json" + +run_case \ + "pre-push-main-blocker: explicit 'git push origin main' denied (jq absent)" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git push origin main"}} +JSON +)" \ + "PATH=$u1_nojq_path" \ + "deny-json" + +run_case \ + "pre-push-main-blocker: refspec 'git push origin main:main' denied" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git push origin main:main"}} +JSON +)" \ + "" \ + "deny-json" + +# --- bare/implicit `git push` while on a branch actually named main: denied, +# with jq present AND with jq absent — needs a real fixture repo since the +# hook reads the *actual* current branch via `git -C ... rev-parse` -------- +u1_main_repo=$(_fresh_dir) +git -C "$u1_main_repo" init -q -b main >/dev/null 2>&1 +git -C "$u1_main_repo" -c user.email=test@example.com -c user.name=test \ + commit -q --allow-empty -m init >/dev/null 2>&1 + +run_case \ + "pre-push-main-blocker: bare 'git push' denied when CURRENT_BRANCH=main (jq present)" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git push"}} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$u1_main_repo" \ + "deny-json" + +run_case \ + "pre-push-main-blocker: bare 'git push' denied when CURRENT_BRANCH=main (jq absent)" \ + ".claude/hooks/pre-push-main-blocker.sh" \ + "$(cat <<'JSON' +{"tool_name":"Bash","tool_input":{"command":"git push"}} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$u1_main_repo PATH=$u1_nojq_path" \ + "deny-json" + +# --- session-start-loader.sh: jq-absent now emits a visible degradation +# block instead of failing open silently. This deliberately supersedes the +# generic fail-open characterization for this one hook in 00-baseline.sh's +# jq-absent loop (that case now asserts stdout is empty, which is no longer +# true by design) — run the harness with +# SKIP="fail-open: .claude/hooks/session-start-loader.sh exits 0 silently with jq absent" +# until 00-baseline.sh itself is updated in a follow-up (out of scope here: +# this lane does not edit 00-baseline.sh, see file header). ------------------ +u1_ssl_dir=$(_fresh_dir) +run_case \ + "session-start-loader: jq absent emits [HOOK DEGRADATION] block" \ + ".claude/hooks/session-start-loader.sh" \ + '{"source":"startup","session_id":"11111111-0000-0000-0000-000000000000"}' \ + "CLAUDE_PROJECT_DIR=$u1_ssl_dir PATH=$u1_nojq_path" \ + "stdout-contains:[HOOK DEGRADATION]" From 5e9953d3812370139d78b70776fc1cc9d07e3af0 Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Fri, 24 Jul 2026 10:11:08 -0500 Subject: [PATCH 2/5] feat(hooks): post-compaction re-orientation context (U9) session-start-loader.sh now branches on SOURCE: a "compact" or "resume" session start appends a [POST-COMPACTION RE-ORIENTATION] block reminding the session to check the native task list, re-read any active plan artifact, and re-read files before editing them rather than trusting what compaction (or a fresh resume) may have dropped or never carried over. debugging-protocol.md's Stale Context Check gains three matching lines: apply the same re-orientation discipline at the plan level, externalize long plans to a file before starting so there is something durable to re-read, and delegate bulk exploration to workers instead of accumulating it in the orchestrator's own context. Extends the U1 MIGRATION.md entry (same new-SessionStart-output category) rather than adding a second one. Co-Authored-By: Claude Fable 5 --- .claude/hooks/session-start-loader.sh | 13 +++++++++++++ .claude/rules/debugging-protocol.md | 3 +++ CHANGELOG.md | 1 + MIGRATION.md | 8 ++++---- scripts/hook-tests.d/10-hooks-lane.sh | 28 +++++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/session-start-loader.sh b/.claude/hooks/session-start-loader.sh index 69e3683..2c6ccd1 100755 --- a/.claude/hooks/session-start-loader.sh +++ b/.claude/hooks/session-start-loader.sh @@ -47,6 +47,19 @@ echo "{\"session_id\": \"$SESSION_ID\", \"started\": \"$(date -Iseconds)\", \"so # Build context message CONTEXT="" +# Post-compaction / resume re-orientation: on "compact", prior context was +# just summarized away; on "resume", this is picking up a session from +# scratch. Either way, don't trust what's already "known" — re-check state +# before continuing (see debugging-protocol.md's Stale Context Check). +if [ "$SOURCE" = "compact" ] || [ "$SOURCE" = "resume" ]; then + CONTEXT="$CONTEXT + +[POST-COMPACTION RE-ORIENTATION] +- Check the native task list for in-flight work before starting anything new +- If a plan artifact is active (artifacts/plan_*.md), re-read it before continuing +- Re-read any file before editing it — do not trust memory of its contents (Stale Context Check, .claude/rules/debugging-protocol.md)" +fi + # Check for active swarm agents ACTIVE_AGENTS=$(find "$STATE_DIR" -maxdepth 1 -name 'session_*.json' -type f 2>/dev/null | wc -l | tr -d ' ') if [ "$ACTIVE_AGENTS" -gt 1 ]; then diff --git a/.claude/rules/debugging-protocol.md b/.claude/rules/debugging-protocol.md index 60956d5..9636891 100644 --- a/.claude/rules/debugging-protocol.md +++ b/.claude/rules/debugging-protocol.md @@ -27,6 +27,9 @@ Long sessions degrade the reliability of what you remember about a file's conten - If more than roughly 20 tool calls have passed since you last read a file, re-read it before editing it. Do not trust your memory of its current state. - After any context compaction event, re-read any file you are about to modify — compaction can silently drop the details you were relying on. - When in doubt about whether context is stale, the cost of re-reading is always lower than the cost of editing blind. +- This applies at the plan level too: after compaction or on resume, re-check task-list state and re-read the active plan artifact before continuing multi-step work, not just the next file you touch. +- Externalize any plan spanning more than a few steps to a file before starting long work, so there is something durable to re-read after compaction instead of relying on conversation history. +- Delegate bulk exploration to workers rather than accumulating it in the orchestrator's own context — that context is exactly what compaction has to compress away first. ## Red Flags diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9d091..156f692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `.claude/rules/hooks-conventions.md`: the framework's first `paths:`-scoped rule (`.claude/hooks/**`, `scripts/**`), dogfooding the tier documented in U8 — shell baseline (`set -u`, shellcheck/`bash -n`, ≤~120 LOC), the fail-open-visibly pattern, the field-scoped `sed` stdin-extraction idiom (citing `branch-pr-discipline.sh`), and the deny/ask JSON output contract. Excluded from `rules-lines`' budget by design (verified: 409 lines counted with the `paths:` frontmatter intact vs. 473 if it were stripped). `docs/customization.md` links it as the tier table's worked example and notes stack packs may use the same mechanism (unit U12) - REVIEW.md freshness contract: `review-steering/SKILL.md`'s generation workflow gains step 8, stamping REVIEW.md's final line with `` (`cat .claude/rules/code-quality.md .claude/rules/security.md | shasum -a 256 | cut -d' ' -f1`); its Refresh Discipline section now cites the mechanical check instead of an unenforced "must never contradict" assertion. New `review-freshness` CI invariant (check #23, `scripts/check-invariants.sh`) recomputes and compares the hash for a tracked root `REVIEW.md`, failing on a missing or stale footer; skips cleanly (this repo ships no REVIEW.md today) when none is tracked (unit U6) - `pre-push-main-blocker.sh` (`artifacts/plan_framework_hardening.md` unit U1): fixes a live false positive where branch names merely containing `main`/`master` as a substring (e.g. `feature/main-cleanup`, `domain-master-list`) were wrongly denied — the destination-branch token is now compared for equality, not word-boundary substring match. Command extraction is now jq-free (the repo's field-scoped sed idiom, generalized and ported to `sed -E` for BSD/GNU portability — the literal BRE form silently extracts nothing on BSD/macOS sed), which closes the previous gap where a missing `jq` skipped this hook entirely, including the bare/implicit `git push`-while-on-main case that `permissions.deny` can't express. `session-start-loader.sh` now emits a `[HOOK DEGRADATION]` context block naming exactly what's degraded when `jq` is absent instead of failing open silently; `docs/hooks.md` gains a consolidated Degradation visibility subsection +- `session-start-loader.sh` (unit U9): emits a `[POST-COMPACTION RE-ORIENTATION]` context block on `compact`/`resume` session starts — check the native task list, re-read the active plan artifact, re-read any file before editing it — so a session doesn't trust stale in-context knowledge after a compaction event or a resumed session. `.claude/rules/debugging-protocol.md`'s Stale Context Check gains matching plan-level guidance (externalize long plans to a file; delegate bulk exploration to workers) ## [4.0.0] - 2026-07-23 diff --git a/MIGRATION.md b/MIGRATION.md index f11c10a..57125d7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -260,12 +260,12 @@ and triggers unchanged). If your own agents preloaded `designing-apis` or `appli ## v4.0.x → next release -### Session start now surfaces hook-degradation context +### Session start now surfaces hook-degradation and post-compaction re-orientation context -**Who is affected:** anyone whose environment is missing `jq`. Everyone else sees no change. +**Who is affected:** anyone whose environment is missing `jq`, and anyone whose session resumes or continues after a context-compaction event. Everyone else sees no change. -**What breaks:** nothing — this is new, additive `SessionStart` output, not a behavior change to any tool call. +**What breaks:** nothing — both are new, additive `SessionStart` output, not a behavior change to any tool call. -**Action required:** none. If you see a `[HOOK DEGRADATION]` block at session start, it means `jq` isn't on `PATH` in that environment; install `jq` to restore full hook coverage. `permissions.deny` enforcement is unaffected either way — it never depended on hooks or `jq`. +**Action required:** none. A `[HOOK DEGRADATION]` block means `jq` isn't on `PATH` in that environment; install it to restore full hook coverage (`permissions.deny` enforcement is unaffected either way — it never depended on hooks or `jq`). A `[POST-COMPACTION RE-ORIENTATION]` block on `compact`/`resume` session starts is a reminder, not an error: check the native task list and any active plan artifact before continuing, and re-read files before editing them — per the Stale Context Check in `.claude/rules/debugging-protocol.md`. diff --git a/scripts/hook-tests.d/10-hooks-lane.sh b/scripts/hook-tests.d/10-hooks-lane.sh index 8a04f86..4fad4f9 100755 --- a/scripts/hook-tests.d/10-hooks-lane.sh +++ b/scripts/hook-tests.d/10-hooks-lane.sh @@ -111,3 +111,31 @@ run_case \ '{"source":"startup","session_id":"11111111-0000-0000-0000-000000000000"}' \ "CLAUDE_PROJECT_DIR=$u1_ssl_dir PATH=$u1_nojq_path" \ "stdout-contains:[HOOK DEGRADATION]" + +# ============================================================================ +# U9 — post-compaction re-orientation +# ============================================================================ + +u9_compact_dir=$(_fresh_dir) +run_case \ + "session-start-loader: source=compact emits [POST-COMPACTION RE-ORIENTATION] block" \ + ".claude/hooks/session-start-loader.sh" \ + '{"source":"compact","session_id":"22222222-0000-0000-0000-000000000000"}' \ + "CLAUDE_PROJECT_DIR=$u9_compact_dir" \ + "stdout-contains:POST-COMPACTION RE-ORIENTATION" + +u9_resume_dir=$(_fresh_dir) +run_case \ + "session-start-loader: source=resume emits [POST-COMPACTION RE-ORIENTATION] block" \ + ".claude/hooks/session-start-loader.sh" \ + '{"source":"resume","session_id":"33333333-0000-0000-0000-000000000000"}' \ + "CLAUDE_PROJECT_DIR=$u9_resume_dir" \ + "stdout-contains:POST-COMPACTION RE-ORIENTATION" + +u9_startup_dir=$(_fresh_dir) +run_case \ + "session-start-loader: source=startup does not emit [POST-COMPACTION RE-ORIENTATION] (silent on a fresh project)" \ + ".claude/hooks/session-start-loader.sh" \ + '{"source":"startup","session_id":"44444444-0000-0000-0000-000000000000"}' \ + "CLAUDE_PROJECT_DIR=$u9_startup_dir" \ + "exit0-silent" From c8793c7ace1d2d463d7002f2305effd52ac73a66 Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Fri, 24 Jul 2026 10:14:37 -0500 Subject: [PATCH 3/5] feat(hooks): stop-validator detects unpushed work, remote-aware (U2) stop-validator.sh only warned about uncommitted files. It now also warns about unpushed commits, remote-aware: if `git remote` is empty the whole check is skipped (a naive "commits not on any remote" count would warn on every commit in every local-only repo, which is noise, not a reminder). With a remote configured: count commits ahead of the configured upstream via `git rev-list --count @{upstream}..HEAD` when an upstream tracking branch exists, or commits unreachable from any remote-tracking ref via `git rev-list --count HEAD --not --remotes` when it doesn't (with `git push -u` guidance instead). Warns only when the count is greater than zero. docs/hooks.md's Built-in Hooks row and Session Management capability list both mention the new check. Co-Authored-By: Claude Fable 5 --- .claude/hooks/stop-validator.sh | 36 +++++++++++++ CHANGELOG.md | 1 + docs/hooks.md | 3 +- scripts/hook-tests.d/10-hooks-lane.sh | 75 +++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/stop-validator.sh b/.claude/hooks/stop-validator.sh index 09a7e19..a6e0f2c 100755 --- a/.claude/hooks/stop-validator.sh +++ b/.claude/hooks/stop-validator.sh @@ -47,6 +47,42 @@ if [ -d "$PROJECT_DIR/.git" ]; then - Or track remaining work in the task tracker / GitHub Issues ---" fi + + # Check for unpushed commits — remote-aware. A repo with zero remotes + # configured skips this section entirely: counting "commits not on any + # remote" without that guard warns on every commit in every local-only + # repo, which is noise, not a reminder. + REMOTES=$(git -C "$PROJECT_DIR" remote 2>/dev/null) + if [ -n "$REMOTES" ]; then + UPSTREAM_REF=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true) + if [ -n "$UPSTREAM_REF" ]; then + # Has a configured upstream: count commits it doesn't have yet. + AHEAD=$(git -C "$PROJECT_DIR" rev-list --count '@{upstream}..HEAD' 2>/dev/null || echo 0) + [[ "$AHEAD" =~ ^[0-9]+$ ]] || AHEAD=0 + if [ "$AHEAD" -gt 0 ]; then + echo " +--- +[UNPUSHED WORK REMINDER] +- $AHEAD commit(s) ahead of $UPSTREAM_REF +- Push before ending: git push +---" + fi + else + # No upstream tracking branch configured at all: count commits + # unreachable from any remote-tracking ref. + CURRENT_BRANCH=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "your-branch") + UNPUSHED=$(git -C "$PROJECT_DIR" rev-list --count HEAD --not --remotes 2>/dev/null || echo 0) + [[ "$UNPUSHED" =~ ^[0-9]+$ ]] || UNPUSHED=0 + if [ "$UNPUSHED" -gt 0 ]; then + echo " +--- +[UNPUSHED WORK REMINDER] +- $UNPUSHED commit(s) with no upstream tracking branch configured +- Push and set upstream: git push -u origin $CURRENT_BRANCH +---" + fi + fi + fi fi exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 156f692..e798511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - REVIEW.md freshness contract: `review-steering/SKILL.md`'s generation workflow gains step 8, stamping REVIEW.md's final line with `` (`cat .claude/rules/code-quality.md .claude/rules/security.md | shasum -a 256 | cut -d' ' -f1`); its Refresh Discipline section now cites the mechanical check instead of an unenforced "must never contradict" assertion. New `review-freshness` CI invariant (check #23, `scripts/check-invariants.sh`) recomputes and compares the hash for a tracked root `REVIEW.md`, failing on a missing or stale footer; skips cleanly (this repo ships no REVIEW.md today) when none is tracked (unit U6) - `pre-push-main-blocker.sh` (`artifacts/plan_framework_hardening.md` unit U1): fixes a live false positive where branch names merely containing `main`/`master` as a substring (e.g. `feature/main-cleanup`, `domain-master-list`) were wrongly denied — the destination-branch token is now compared for equality, not word-boundary substring match. Command extraction is now jq-free (the repo's field-scoped sed idiom, generalized and ported to `sed -E` for BSD/GNU portability — the literal BRE form silently extracts nothing on BSD/macOS sed), which closes the previous gap where a missing `jq` skipped this hook entirely, including the bare/implicit `git push`-while-on-main case that `permissions.deny` can't express. `session-start-loader.sh` now emits a `[HOOK DEGRADATION]` context block naming exactly what's degraded when `jq` is absent instead of failing open silently; `docs/hooks.md` gains a consolidated Degradation visibility subsection - `session-start-loader.sh` (unit U9): emits a `[POST-COMPACTION RE-ORIENTATION]` context block on `compact`/`resume` session starts — check the native task list, re-read the active plan artifact, re-read any file before editing it — so a session doesn't trust stale in-context knowledge after a compaction event or a resumed session. `.claude/rules/debugging-protocol.md`'s Stale Context Check gains matching plan-level guidance (externalize long plans to a file; delegate bulk exploration to workers) +- `stop-validator.sh` (unit U2): warns about unpushed commits too, remote-aware — ahead-of-upstream count via `git rev-list` (with the push command), or `git push -u` guidance when a remote is configured but no upstream tracking branch is set. Silent in repos with no `git remote` configured at all, instead of the naive "commits not on any remote" count that would warn on every commit in every local-only repo ## [4.0.0] - 2026-07-23 diff --git a/docs/hooks.md b/docs/hooks.md index 9df01b4..43b3b3c 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -12,7 +12,7 @@ Hooks run automatically at key points in Claude Code's lifecycle. | `pre-push-main-blocker.sh` | PreToolUse (Bash) | Block direct pushes to main/master branch | | `pre-commit-verification.sh` | PreToolUse (Bash) | Pre-commit quality checks | | `post-tool-use-tracker.sh` | PostToolUse | Track file changes | -| `stop-validator.sh` | Stop | Release file locks, cleanup session state, warn about uncommitted changes | +| `stop-validator.sh` | Stop | Release file locks, cleanup session state, warn about uncommitted and unpushed work | | `subagent-stop-validator.sh` | SubagentStop | Log swarm worker completion | | `post-edit-lint.sh` | PostToolUse | Auto-format after edits; surfaces only unfixable issues | | `branch-pr-discipline.sh` | PreToolUse (Bash) | Warn-only branch/PR hygiene checks | @@ -62,6 +62,7 @@ Enforces trunk-based development by blocking pushes to main/master: - Supports handoff messages between sessions - Auto-cleans stale sessions older than 24 hours - Warns about uncommitted changes on session stop +- Warns about unpushed commits too, remote-aware: ahead-of-upstream count (with the push command) or `git push -u` guidance when no upstream is configured — silent in repos with no `git remote` configured at all - Releases file locks before exit ## Creating a Hook diff --git a/scripts/hook-tests.d/10-hooks-lane.sh b/scripts/hook-tests.d/10-hooks-lane.sh index 4fad4f9..b45310e 100755 --- a/scripts/hook-tests.d/10-hooks-lane.sh +++ b/scripts/hook-tests.d/10-hooks-lane.sh @@ -139,3 +139,78 @@ run_case \ '{"source":"startup","session_id":"44444444-0000-0000-0000-000000000000"}' \ "CLAUDE_PROJECT_DIR=$u9_startup_dir" \ "exit0-silent" + +# ============================================================================ +# U2 — stop-validator detects unpushed work, remote-aware +# ============================================================================ + +# Shared throwaway bare "remote" for the fixtures below — a local filesystem +# path works fine as a git remote; no network needed. +u2_remote=$(_fresh_dir) +git init -q --bare "$u2_remote" >/dev/null 2>&1 + +# --- ahead of a configured upstream: warns with count + push command ------ +u2_ahead_repo=$(_fresh_dir) +git -C "$u2_ahead_repo" init -q >/dev/null 2>&1 +git -C "$u2_ahead_repo" -c user.email=test@example.com -c user.name=test \ + commit -q --allow-empty -m init >/dev/null 2>&1 +git -C "$u2_ahead_repo" remote add origin "$u2_remote" >/dev/null 2>&1 +git -C "$u2_ahead_repo" push -q -u origin HEAD >/dev/null 2>&1 +git -C "$u2_ahead_repo" -c user.email=test@example.com -c user.name=test \ + commit -q --allow-empty -m second >/dev/null 2>&1 + +run_case \ + "stop-validator: ahead-of-upstream reminder includes the commit count" \ + ".claude/hooks/stop-validator.sh" \ + '{"session_id":"u2-ahead","stop_hook_active":false}' \ + "CLAUDE_PROJECT_DIR=$u2_ahead_repo" \ + "stdout-contains:1 commit(s) ahead" + +run_case \ + "stop-validator: ahead-of-upstream reminder includes the push command" \ + ".claude/hooks/stop-validator.sh" \ + '{"session_id":"u2-ahead","stop_hook_active":false}' \ + "CLAUDE_PROJECT_DIR=$u2_ahead_repo" \ + "stdout-contains:git push" + +# --- clean and pushed: silent (has a real upstream, ahead=0) --------------- +u2_clean_repo=$(_fresh_dir) +git -C "$u2_clean_repo" init -q >/dev/null 2>&1 +git -C "$u2_clean_repo" -c user.email=test@example.com -c user.name=test \ + commit -q --allow-empty -m init >/dev/null 2>&1 +git -C "$u2_clean_repo" remote add origin "$u2_remote" >/dev/null 2>&1 +git -C "$u2_clean_repo" push -q -u origin HEAD >/dev/null 2>&1 + +run_case \ + "stop-validator: silent when pushed and up to date with a configured remote" \ + ".claude/hooks/stop-validator.sh" \ + '{"session_id":"u2-clean","stop_hook_active":false}' \ + "CLAUDE_PROJECT_DIR=$u2_clean_repo" \ + "exit0-silent" + +# --- remote configured but no upstream tracking branch: git push -u guidance +u2_nostream_repo=$(_fresh_dir) +git -C "$u2_nostream_repo" init -q >/dev/null 2>&1 +git -C "$u2_nostream_repo" -c user.email=test@example.com -c user.name=test \ + commit -q --allow-empty -m init >/dev/null 2>&1 +git -C "$u2_nostream_repo" remote add origin "$u2_remote" >/dev/null 2>&1 + +run_case \ + "stop-validator: git push -u guidance when a remote exists but no upstream is configured" \ + ".claude/hooks/stop-validator.sh" \ + '{"session_id":"u2-nostream","stop_hook_active":false}' \ + "CLAUDE_PROJECT_DIR=$u2_nostream_repo" \ + "stdout-contains:git push -u" + +# --- zero-remote repo: no unpushed warning at all -------------------------- +u2_noremote_repo=$(_fresh_dir) +git -C "$u2_noremote_repo" init -q >/dev/null 2>&1 +git -C "$u2_noremote_repo" -c user.email=test@example.com -c user.name=test \ + commit -q --allow-empty -m init >/dev/null 2>&1 + +run_case \ + "stop-validator: silent — no unpushed warning in a zero-remote repo" \ + ".claude/hooks/stop-validator.sh" \ + '{"session_id":"u2-noremote","stop_hook_active":false}' \ + "CLAUDE_PROJECT_DIR=$u2_noremote_repo" \ + "exit0-silent" From d031e5490475b638500e18c55869a2eaf07c7716 Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Fri, 24 Jul 2026 10:25:05 -0500 Subject: [PATCH 4/5] feat(hooks): config-write ask-gate + Bash secret-write scan (U4) pre-tool-use-validator.sh now asks (not silently proceeds) before a direct Write/Edit to .claude/settings.json, any file under .claude/rules/, or the root CLAUDE.md - the paths /tailor proposes changes to rather than writing directly. Removes the now-contradicting comment claiming these paths are deliberately unprotected/user-configurable. Extends the six secret-shape checks (refactored into a shared detect_secret function to avoid duplicating them) to also scan Bash commands that redirect or heredoc content into a file, closing the blind spot where a heredoc'd .env write bypassed Write/Edit-only detection entirely. Along the way, fixed two bugs surfaced while building this (both pre-existing, not introduced by this change): - A local variable named BASH_COMMAND collided with bash's own special variable of that name (always reflects "the command about to execute"), silently getting clobbered on the next statement rather than holding the extracted value - renamed to TOOL_COMMAND. - The private-key pattern's leading dashes were parsed as grep options on BSD/macOS grep instead of a pattern, so that check silently never matched on those systems; added -e to all six patterns. tailor/SKILL.md gains one line noting the propose-only contract is now hook-backed; docs/hooks.md gets a table-row update, a new Config-Write Ask-Gate section, and an honest limitation note (Trivy CI remains the actual backstop). New MIGRATION.md entry for the new ask prompts. Known gap (flagged, not fixed here - out of this unit's file list): the Bash-command scan only fires in practice if pre-tool-use-validator.sh is also registered under the PreToolUse "Bash" matcher in settings.json, which today only wires it to Write/Edit/MultiEdit/NotebookEdit. The harness cases exercise the script directly and pass regardless, but the capability is inert against real Bash tool calls until that wiring is added. Co-Authored-By: Claude Fable 5 --- .claude/hooks/pre-tool-use-validator.sh | 132 +++++++++++++++++------- .claude/skills/tailor/SKILL.md | 2 +- CHANGELOG.md | 1 + MIGRATION.md | 8 ++ docs/hooks.md | 15 ++- scripts/hook-tests.d/10-hooks-lane.sh | 61 +++++++++++ 6 files changed, 176 insertions(+), 43 deletions(-) diff --git a/.claude/hooks/pre-tool-use-validator.sh b/.claude/hooks/pre-tool-use-validator.sh index 86b143b..b140928 100755 --- a/.claude/hooks/pre-tool-use-validator.sh +++ b/.claude/hooks/pre-tool-use-validator.sh @@ -12,6 +12,83 @@ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null || true) SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || true) +# detect_secret: shared secret-shape scan for both Write/Edit content (below) +# and Bash commands that redirect/heredoc content into a file. Sets +# SECRET_DETECTED / SECRET_TYPE as a side effect rather than echoing a +# classification back through a subshell. +detect_secret() { + local text="$1" + SECRET_DETECTED=false + SECRET_TYPE="" + + # Generic secrets (API keys, passwords, tokens) + # `-e` on every pattern below (not just this one) is deliberate: a + # pattern beginning with a literal `-` (the private-key one, below) gets + # parsed as an option by BSD grep without it, erroring out instead of + # matching — `-e` marks the argument as a pattern unconditionally, so + # this holds regardless of what a pattern happens to start with. + if echo "$text" | grep -qiE -e '(api[_-]?key|secret|password|token|credential).*[=:][[:space:]]*["\x27]?[a-zA-Z0-9+/]{20,}'; then + SECRET_DETECTED=true + SECRET_TYPE="generic secret" + fi + + # AWS access keys (AKIA followed by 16 alphanumeric chars) + if echo "$text" | grep -qE -e 'AKIA[0-9A-Z]{16}'; then + SECRET_DETECTED=true + SECRET_TYPE="AWS access key" + fi + + # JWT tokens (three base64 segments separated by dots) + if echo "$text" | grep -qE -e 'eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+'; then + SECRET_DETECTED=true + SECRET_TYPE="JWT token" + fi + + # Environment variable exports with secrets + if echo "$text" | grep -qiE -e 'export\s+(API_KEY|SECRET|PASSWORD|TOKEN|CREDENTIAL|AWS_|PRIVATE_KEY)=["\x27]?[a-zA-Z0-9+/]{20,}'; then + SECRET_DETECTED=true + SECRET_TYPE="exported secret" + fi + + # GitHub personal access tokens + if echo "$text" | grep -qE -e 'ghp_[a-zA-Z0-9]{36}'; then + SECRET_DETECTED=true + SECRET_TYPE="GitHub personal access token" + fi + + # Private keys (PEM format) — this pattern starts with a literal `-----`; + # without `-e` above, BSD grep (macOS) treats that as option flags and + # errors out instead of matching, silently never detecting a real + # private key on those systems. + if echo "$text" | grep -qE -e '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then + SECRET_DETECTED=true + SECRET_TYPE="private key" + fi +} + +# Bash secret-write scan: a redirect or heredoc can write secret-shaped +# content straight to disk without ever going through Write/Edit, bypassing +# the detection below entirely (e.g. a heredoc'd .env write). Ask — never +# deny, this hook can't tell a real secret from a placeholder — when the +# command both writes to a file and contains secret-shaped content. This +# covers the pre-commit, redirect-syntax path only; Trivy's CI secret-scan +# job remains the backstop for anything this and the Write/Edit scan below +# both miss (see docs/hooks.md's Secret Detection section). +if [ "$TOOL_NAME" = "Bash" ]; then + # Named TOOL_COMMAND, not BASH_COMMAND: the latter is bash's own special + # variable (always reflects "the command currently being executed") — + # assigning to it here would silently get clobbered by the shell on the + # very next statement, not hold the extracted value. + TOOL_COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true) + if echo "$TOOL_COMMAND" | grep -qE '(>>|<<|>)'; then + detect_secret "$TOOL_COMMAND" + if [ "$SECRET_DETECTED" = true ]; then + echo "{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"ask\", \"permissionDecisionReason\": \"Potential $SECRET_TYPE detected in a Bash command that writes to a file (redirect/heredoc). Please verify this is not sensitive data.\"}}" + fi + fi + exit 0 +fi + # Exit if not a file operation if [ -z "$FILE_PATH" ]; then exit 0 @@ -46,7 +123,6 @@ if [ -f "$LOCK_FILE" ]; then fi # Block edits to critical system files -# Note: .claude/settings.json and .claude/rules/ are user-configurable PROTECTED_PATTERNS=( ".git/" ".env" @@ -60,6 +136,20 @@ for pattern in "${PROTECTED_PATTERNS[@]}"; do fi done +# Config-write ask-gate: these paths carry /tailor's propose-only contract +# (.claude/skills/tailor/SKILL.md — "/tailor proposes only, it never +# silently writes to .claude/rules/, .claude/settings.json, CLAUDE.md, or +# any other tracked config file"). A direct Write/Edit bypasses that review +# step, so ask rather than silently proceeding — deliberately "ask", not +# "deny": a legitimate direct edit (including this hardening plan's own +# commits) should still be able to proceed once confirmed. +case "$REL_PATH" in + .claude/settings.json|.claude/rules/*|CLAUDE.md) + echo "{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"ask\", \"permissionDecisionReason\": \"Editing $REL_PATH changes tracked framework configuration that /tailor normally proposes for review rather than writing directly (see tailor/SKILL.md's Output Contract). Confirm this direct edit is intentional.\"}}" + exit 0 + ;; +esac + # Skip secret detection for test files if [[ "$REL_PATH" == *.test.ts ]] || [[ "$REL_PATH" == *.spec.ts ]] || \ [[ "$REL_PATH" == *.test.tsx ]] || [[ "$REL_PATH" == *.spec.tsx ]] || \ @@ -69,45 +159,7 @@ if [[ "$REL_PATH" == *.test.ts ]] || [[ "$REL_PATH" == *.spec.ts ]] || \ elif [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // empty' 2>/dev/null || true) - # Check for potential secrets - multiple patterns - SECRET_DETECTED=false - SECRET_TYPE="" - - # Generic secrets (API keys, passwords, tokens) - if echo "$CONTENT" | grep -qiE '(api[_-]?key|secret|password|token|credential).*[=:][[:space:]]*["\x27]?[a-zA-Z0-9+/]{20,}'; then - SECRET_DETECTED=true - SECRET_TYPE="generic secret" - fi - - # AWS access keys (AKIA followed by 16 alphanumeric chars) - if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then - SECRET_DETECTED=true - SECRET_TYPE="AWS access key" - fi - - # JWT tokens (three base64 segments separated by dots) - if echo "$CONTENT" | grep -qE 'eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+'; then - SECRET_DETECTED=true - SECRET_TYPE="JWT token" - fi - - # Environment variable exports with secrets - if echo "$CONTENT" | grep -qiE 'export\s+(API_KEY|SECRET|PASSWORD|TOKEN|CREDENTIAL|AWS_|PRIVATE_KEY)=["\x27]?[a-zA-Z0-9+/]{20,}'; then - SECRET_DETECTED=true - SECRET_TYPE="exported secret" - fi - - # GitHub personal access tokens - if echo "$CONTENT" | grep -qE 'ghp_[a-zA-Z0-9]{36}'; then - SECRET_DETECTED=true - SECRET_TYPE="GitHub personal access token" - fi - - # Private keys (PEM format) - if echo "$CONTENT" | grep -qE '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then - SECRET_DETECTED=true - SECRET_TYPE="private key" - fi + detect_secret "$CONTENT" if [ "$SECRET_DETECTED" = true ]; then echo "{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"ask\", \"permissionDecisionReason\": \"Potential $SECRET_TYPE detected in content. Please verify this is not sensitive data.\"}}" diff --git a/.claude/skills/tailor/SKILL.md b/.claude/skills/tailor/SKILL.md index 99b2267..a3f77a0 100644 --- a/.claude/skills/tailor/SKILL.md +++ b/.claude/skills/tailor/SKILL.md @@ -11,7 +11,7 @@ metadata: The framework's environment-triggered customization engine. Once an adopter's stack is detectable — manifests and lockfiles committed — `/tailor` proposes the configuration the framework should take: filled `tech-strategy.md` golden paths, `REVIEW.md`/`CLAUDE.md` review steering, and a prune list of unused framework pieces. -**`/tailor` proposes only. It never silently writes to `.claude/rules/`, `.claude/settings.json`, `CLAUDE.md`, or any other tracked config file.** Every run ends in a reviewable plan at `scratchpad/tailor-proposal.md` — nothing lands in a tracked file until the user approves it (see Output Contract). +**`/tailor` proposes only. It never silently writes to `.claude/rules/`, `.claude/settings.json`, `CLAUDE.md`, or any other tracked config file.** Every run ends in a reviewable plan at `scratchpad/tailor-proposal.md` — nothing lands in a tracked file until the user approves it (see Output Contract). This promise is now hook-backed at the `ask` tier: `pre-tool-use-validator.sh` asks for confirmation before any direct Write/Edit to these same paths, rather than leaving the propose-only contract as convention alone. ## Detect diff --git a/CHANGELOG.md b/CHANGELOG.md index e798511..ac770da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `pre-push-main-blocker.sh` (`artifacts/plan_framework_hardening.md` unit U1): fixes a live false positive where branch names merely containing `main`/`master` as a substring (e.g. `feature/main-cleanup`, `domain-master-list`) were wrongly denied — the destination-branch token is now compared for equality, not word-boundary substring match. Command extraction is now jq-free (the repo's field-scoped sed idiom, generalized and ported to `sed -E` for BSD/GNU portability — the literal BRE form silently extracts nothing on BSD/macOS sed), which closes the previous gap where a missing `jq` skipped this hook entirely, including the bare/implicit `git push`-while-on-main case that `permissions.deny` can't express. `session-start-loader.sh` now emits a `[HOOK DEGRADATION]` context block naming exactly what's degraded when `jq` is absent instead of failing open silently; `docs/hooks.md` gains a consolidated Degradation visibility subsection - `session-start-loader.sh` (unit U9): emits a `[POST-COMPACTION RE-ORIENTATION]` context block on `compact`/`resume` session starts — check the native task list, re-read the active plan artifact, re-read any file before editing it — so a session doesn't trust stale in-context knowledge after a compaction event or a resumed session. `.claude/rules/debugging-protocol.md`'s Stale Context Check gains matching plan-level guidance (externalize long plans to a file; delegate bulk exploration to workers) - `stop-validator.sh` (unit U2): warns about unpushed commits too, remote-aware — ahead-of-upstream count via `git rev-list` (with the push command), or `git push -u` guidance when a remote is configured but no upstream tracking branch is set. Silent in repos with no `git remote` configured at all, instead of the naive "commits not on any remote" count that would warn on every commit in every local-only repo +- `pre-tool-use-validator.sh` (unit U4): asks (never silently proceeds) before a direct Write/Edit to `.claude/settings.json`, `.claude/rules/*`, or root `CLAUDE.md` — the paths `/tailor` proposes changes to rather than writing directly — backing that propose-only contract mechanically instead of leaving it to convention (removes the now-contradicting "user-configurable" comment). Extends its 6 secret-shape regexes to also scan `Bash` commands that redirect or heredoc content into a file, closing the blind spot where a heredoc'd `.env` write bypassed Write/Edit-only detection; fixes a latent bug (pre-existing, not introduced here) where the private-key pattern's leading `-----` was parsed as a grep option on BSD/macOS grep, silently never matching — `-e` on all 6 patterns now ## [4.0.0] - 2026-07-23 diff --git a/MIGRATION.md b/MIGRATION.md index 57125d7..493ab7d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -268,4 +268,12 @@ and triggers unchanged). If your own agents preloaded `designing-apis` or `appli **Action required:** none. A `[HOOK DEGRADATION]` block means `jq` isn't on `PATH` in that environment; install it to restore full hook coverage (`permissions.deny` enforcement is unaffected either way — it never depended on hooks or `jq`). A `[POST-COMPACTION RE-ORIENTATION]` block on `compact`/`resume` session starts is a reminder, not an error: check the native task list and any active plan artifact before continuing, and re-read files before editing them — per the Stale Context Check in `.claude/rules/debugging-protocol.md`. +### New `ask`-tier prompts: config-file edits and Bash secret-shaped writes + +**Who is affected:** anyone (or any automation) that directly edits `.claude/settings.json`, any file under `.claude/rules/`, or the root `CLAUDE.md`; and anyone running a Bash command that redirects or heredocs secret-shaped content into a file (an AWS-key-shaped string, a JWT, a private-key block, etc.). + +**What breaks:** nothing breaks outright — both cases now pause for an explicit confirmation (`ask`, not `deny`) instead of proceeding silently. Fully automated/unattended pipelines that do either will need to handle the prompt. + +**Action required:** confirm the prompt to proceed as before. The config-file gate closes a mechanical-backing gap in `/tailor`'s "proposes only, never silently writes" contract (`.claude/skills/tailor/SKILL.md`); the Bash scan closes a blind spot where a heredoc'd `.env` write bypassed the existing Write/Edit secret detection entirely. Neither is a complete guarantee — see `docs/hooks.md`'s Secret Detection section for the standing limitation and the Trivy CI backstop. + diff --git a/docs/hooks.md b/docs/hooks.md index 43b3b3c..32d37ca 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -7,7 +7,7 @@ Hooks run automatically at key points in Claude Code's lifecycle. | Hook | Event | Purpose | |------|-------|---------| | `session-start-loader.sh` | SessionStart | Load session context, detect active swarm agents, process handoffs, cleanup stale sessions | -| `pre-tool-use-validator.sh` | PreToolUse | File locking, secret detection, protected file enforcement | +| `pre-tool-use-validator.sh` | PreToolUse | File locking, secret detection (Write/Edit + Bash redirects/heredocs), protected file enforcement, config-write ask-gate | | `dangerous-command-guard.sh` | PreToolUse (Bash) | Guard against dangerous shell commands (force push, rm -rf, etc.) | | `pre-push-main-blocker.sh` | PreToolUse (Bash) | Block direct pushes to main/master branch | | `pre-commit-verification.sh` | PreToolUse (Bash) | Pre-commit quality checks | @@ -39,7 +39,9 @@ Scans Write/Edit content for 6 secret patterns: 5. GitHub personal access tokens (`ghp_...`) 6. Private keys (PEM format) -Test files (`*.test.ts`, `*.spec.ts`, etc.) are excluded to reduce false positives. +Test files (`*.test.ts`, `*.spec.ts`, etc.) are excluded to reduce false positives. The same 6 patterns also scan `Bash` commands that redirect or heredoc content into a file (`>`, `>>`, `<<`) — closing the gap where a heredoc'd `.env` write bypassed Write/Edit-only detection entirely. + +**Limitation**: this is a Write/Edit + Bash-redirect matcher, not a general secret scanner — it can't see secrets written by any other means (a script invoked some other way, an MCP tool, etc.), and pattern matching always has false negatives. Trivy's CI secret-scan job (`framework-invariants.yml`) is the actual backstop; treat this hook as an early, partial warning, not the guarantee. ### Protected Files (pre-tool-use-validator.sh) @@ -48,6 +50,15 @@ Blocks modifications to critical system files: - `.env` - `.mcp.json` +### Config-Write Ask-Gate (pre-tool-use-validator.sh) + +Asks for confirmation (not a hard block) before a direct Write/Edit to: +- `.claude/settings.json` +- `.claude/rules/*` +- root `CLAUDE.md` + +These are the same paths `/tailor` proposes changes to rather than writing directly (see `tailor/SKILL.md`'s Output Contract) — this hook backs that contract mechanically instead of leaving it as convention only. + ### Push Blocking (pre-push-main-blocker.sh) Enforces trunk-based development by blocking pushes to main/master: diff --git a/scripts/hook-tests.d/10-hooks-lane.sh b/scripts/hook-tests.d/10-hooks-lane.sh index b45310e..1be28d0 100755 --- a/scripts/hook-tests.d/10-hooks-lane.sh +++ b/scripts/hook-tests.d/10-hooks-lane.sh @@ -214,3 +214,64 @@ run_case \ '{"session_id":"u2-noremote","stop_hook_active":false}' \ "CLAUDE_PROJECT_DIR=$u2_noremote_repo" \ "exit0-silent" + +# ============================================================================ +# U4 — config-write ask-gate + Bash secret-write scan +# ============================================================================ + +u4_ptu_dir=$(_fresh_dir) + +run_case \ + "pre-tool-use-validator: Write to .claude/rules/x.md asks (tailor propose-only contract)" \ + ".claude/hooks/pre-tool-use-validator.sh" \ + "$(cat <<'JSON' +{"tool_name":"Write","tool_input":{"file_path":".claude/rules/x.md","content":"some rule content"}} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$u4_ptu_dir" \ + "ask-json" + +run_case \ + "pre-tool-use-validator: Write to .claude/settings.json asks" \ + ".claude/hooks/pre-tool-use-validator.sh" \ + "$(cat <<'JSON' +{"tool_name":"Write","tool_input":{"file_path":".claude/settings.json","content":"{}"}} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$u4_ptu_dir" \ + "ask-json" + +run_case \ + "pre-tool-use-validator: Write to root CLAUDE.md asks" \ + ".claude/hooks/pre-tool-use-validator.sh" \ + "$(cat <<'JSON' +{"tool_name":"Write","tool_input":{"file_path":"CLAUDE.md","content":"# hi"}} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$u4_ptu_dir" \ + "ask-json" + +# Split across two vars so this fixture file's own text never contains a +# contiguous AWS-key-shaped string (would false-positive this repo's own +# Trivy secret-scan CI job) — same precaution as 00-baseline.sh. +_u4_akid_prefix="AKIA" +_u4_akid_suffix="TESTTESTTESTTEST" +run_case \ + "pre-tool-use-validator: Bash heredoc writing an AWS-key-shaped string asks" \ + ".claude/hooks/pre-tool-use-validator.sh" \ + "$(cat < .env <<'EOF'\nAWS_KEY=${_u4_akid_prefix}${_u4_akid_suffix}\nEOF"}} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$u4_ptu_dir" \ + "ask-json" + +run_case \ + "pre-tool-use-validator: ordinary source Write is unaffected (allowed, silent)" \ + ".claude/hooks/pre-tool-use-validator.sh" \ + "$(cat <<'JSON' +{"tool_name":"Write","tool_input":{"file_path":"src/u4-example.ts","content":"export const answer = 42;"}} +JSON +)" \ + "CLAUDE_PROJECT_DIR=$u4_ptu_dir" \ + "exit0-silent" From 84865bc6a76542267c97dd0020e0f362b7781d05 Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Fri, 24 Jul 2026 10:32:05 -0500 Subject: [PATCH 5/5] fix(hooks): baseline supersession, fixture hermeticity, wire validator for Bash (PR1 follow-through) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrator landing fixes for the hooks lane: - 00-baseline.sh: session-start-loader's jq-absent case now expects the [HOOK DEGRADATION] announcement U1 deliberately introduced (the old silent-exit pin is superseded; the other four hooks keep it). - 10-hooks-lane.sh: the clean-and-pushed stop-validator fixture gets its own bare remote — sharing one across unrelated-history fixtures let the second setup silently fail non-fast-forward, making the case order-dependent (deterministically red on landing). - settings.json: register pre-tool-use-validator under the Bash matcher so U4's redirect/heredoc secret scan actually fires for Bash tool calls; smoke-verified plain commands stay silent-allowed. Co-Authored-By: Claude Fable 5 --- .claude/settings.json | 5 +++++ CHANGELOG.md | 1 + scripts/hook-tests.d/00-baseline.sh | 14 +++++++++++--- scripts/hook-tests.d/10-hooks-lane.sh | 7 ++++++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index a8538d8..7d2475b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -334,6 +334,11 @@ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/dangerous-command-guard.sh", "timeout": 5 }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-tool-use-validator.sh", + "timeout": 5 + }, { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-verification.sh", diff --git a/CHANGELOG.md b/CHANGELOG.md index ac770da..333d2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `session-start-loader.sh` (unit U9): emits a `[POST-COMPACTION RE-ORIENTATION]` context block on `compact`/`resume` session starts — check the native task list, re-read the active plan artifact, re-read any file before editing it — so a session doesn't trust stale in-context knowledge after a compaction event or a resumed session. `.claude/rules/debugging-protocol.md`'s Stale Context Check gains matching plan-level guidance (externalize long plans to a file; delegate bulk exploration to workers) - `stop-validator.sh` (unit U2): warns about unpushed commits too, remote-aware — ahead-of-upstream count via `git rev-list` (with the push command), or `git push -u` guidance when a remote is configured but no upstream tracking branch is set. Silent in repos with no `git remote` configured at all, instead of the naive "commits not on any remote" count that would warn on every commit in every local-only repo - `pre-tool-use-validator.sh` (unit U4): asks (never silently proceeds) before a direct Write/Edit to `.claude/settings.json`, `.claude/rules/*`, or root `CLAUDE.md` — the paths `/tailor` proposes changes to rather than writing directly — backing that propose-only contract mechanically instead of leaving it to convention (removes the now-contradicting "user-configurable" comment). Extends its 6 secret-shape regexes to also scan `Bash` commands that redirect or heredoc content into a file, closing the blind spot where a heredoc'd `.env` write bypassed Write/Edit-only detection; fixes a latent bug (pre-existing, not introduced here) where the private-key pattern's leading `-----` was parsed as a grep option on BSD/macOS grep, silently never matching — `-e` on all 6 patterns now +- `pre-tool-use-validator.sh` is now also registered under the PreToolUse `Bash` matcher in `settings.json`, activating U4's redirect/heredoc secret scan for real Bash tool calls (it was previously wired to Write/Edit-family tools only) ## [4.0.0] - 2026-07-23 diff --git a/scripts/hook-tests.d/00-baseline.sh b/scripts/hook-tests.d/00-baseline.sh index 35a70e0..5adf457 100755 --- a/scripts/hook-tests.d/00-baseline.sh +++ b/scripts/hook-tests.d/00-baseline.sh @@ -91,14 +91,15 @@ JSON "CLAUDE_PROJECT_DIR=$ssl_dir" \ "stdout-contains:Active agents in project: 2" -# --- fail-open: with jq absent, every hook this plan touches exits 0, silently +# --- fail-open: with jq absent, hooks exit 0 without blocking. Most stay +# silent; session-start-loader is the designated exception since U1 — it +# announces the degraded guardrail set (that IS its jq-absent behavior now). nojq_path=$(path_without_jq) for hook in \ ".claude/hooks/pre-commit-verification.sh" \ ".claude/hooks/pre-push-main-blocker.sh" \ ".claude/hooks/stop-validator.sh" \ - ".claude/hooks/pre-tool-use-validator.sh" \ - ".claude/hooks/session-start-loader.sh" + ".claude/hooks/pre-tool-use-validator.sh" do run_case \ "fail-open: $hook exits 0 silently with jq absent" \ @@ -107,3 +108,10 @@ do "PATH=$nojq_path" \ "exit0-silent" done + +run_case \ + "fail-open: session-start-loader announces [HOOK DEGRADATION] with jq absent (U1)" \ + ".claude/hooks/session-start-loader.sh" \ + '{}' \ + "PATH=$nojq_path" \ + "stdout-contains:[HOOK DEGRADATION]" diff --git a/scripts/hook-tests.d/10-hooks-lane.sh b/scripts/hook-tests.d/10-hooks-lane.sh index 1be28d0..25ee383 100755 --- a/scripts/hook-tests.d/10-hooks-lane.sh +++ b/scripts/hook-tests.d/10-hooks-lane.sh @@ -174,11 +174,16 @@ run_case \ "stdout-contains:git push" # --- clean and pushed: silent (has a real upstream, ahead=0) --------------- +# Own bare remote: sharing $u2_remote would silently reject this repo's push +# (unrelated history, non-fast-forward), leaving no upstream and making the +# case order-dependent on whichever fixture pushed first. +u2_clean_remote=$(_fresh_dir) +git init -q --bare "$u2_clean_remote" >/dev/null 2>&1 u2_clean_repo=$(_fresh_dir) git -C "$u2_clean_repo" init -q >/dev/null 2>&1 git -C "$u2_clean_repo" -c user.email=test@example.com -c user.name=test \ commit -q --allow-empty -m init >/dev/null 2>&1 -git -C "$u2_clean_repo" remote add origin "$u2_remote" >/dev/null 2>&1 +git -C "$u2_clean_repo" remote add origin "$u2_clean_remote" >/dev/null 2>&1 git -C "$u2_clean_repo" push -q -u origin HEAD >/dev/null 2>&1 run_case \