From 55347445cc7ebe5fd8abad0e30b62de7a3cab889 Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Fri, 3 Jul 2026 15:29:20 +0200 Subject: [PATCH 1/2] feat(hooks): config-protection PreToolUse guards quality-gate files Blocks Edit/Write to guardrails (settings.json + .claude/settings*, lib/gitflow.sh, .githooks/*, doctor.sh, hooks/*.sh self-guard, lib/tests/*, lint) so a gate can't be weakened to pass an error. Bypass = one-shot sentinel .claude/.config-edit-ok (non-empty reason, logged+consumed), not an env-var. Adaptation from the ECC second-look (BDR-047 corrob): own bash idiom, not ECC's Node dispatcher. shellcheck clean, test 20/20. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XpphkdTosUzokBDNG7PToS --- hooks/config-protection.sh | 65 +++++++++++++++++++++++++++++ lib/tests/config-protection.test.sh | 57 +++++++++++++++++++++++++ settings.json | 10 +++++ 3 files changed, 132 insertions(+) create mode 100755 hooks/config-protection.sh create mode 100755 lib/tests/config-protection.test.sh diff --git a/hooks/config-protection.sh b/hooks/config-protection.sh new file mode 100755 index 0000000..07f96ff --- /dev/null +++ b/hooks/config-protection.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# config-protection.sh +# +# PreToolUse hook (Edit|Write|MultiEdit). Blocks edits to this config's +# quality-gate files — the guardrails an agent must not silently weaken to make +# an error "pass" (permission/hook registry, gitflow enforcement, the git +# pre-commit guard, the hooks themselves, the test suite, the health diagnostic, +# lint config). Exit 2 blocks the tool call and feeds the message back to the +# model (Claude Code PreToolUse contract). +# +# It fires only on the model's Edit/Write tool calls — never on shell-level file +# ops (the cp/ln in install.sh, link.sh), so bootstrap/deploy is unaffected. +# +# One-shot escape hatch: create .claude/.config-edit-ok (CWD-relative) with a +# NON-EMPTY reason inside; the hook logs the reason, consumes (rm) the sentinel, +# and allows that single edit. It never persists — a lingering sentinel would be +# a footgun. Discipline, per CLAUDE.md "Root causes only. No temp fixes.": fix +# the code, don't loosen the gate. Fails OPEN (exit 0) on parse failure so it can +# never wedge editing. + +set -euo pipefail + +log="${HOME}/.claude/logs/config-protection.log" +sentinel="${PWD}/.claude/.config-edit-ok" + +input="$(cat)" +path="$(printf '%s' "$input" \ + | python3 -c 'import sys, json; print(json.load(sys.stdin).get("tool_input", {}).get("file_path", ""))' \ + 2>/dev/null || true)" +[ -z "$path" ] && exit 0 + +# Guardrail files, matched by path suffix (covers both the repo source and the +# deployed ~/.claude copy). Precise: lib/gitflow.sh only, not gitflow-migrate.sh. +case "$path" in + */.claude/settings.json|*/.claude/settings.local.json|*/claude/settings.json) ;; + */lib/gitflow.sh|*/.githooks/*|*/doctor.sh) ;; + */hooks/*.sh|*/lib/tests/*) ;; + */.shellcheckrc|*/.markdownlint.json|*/.editorconfig) ;; + *) exit 0 ;; +esac + +# One-shot sentinel bypass: non-empty reason required; consumed on sight. +if [ -f "$sentinel" ]; then + reason="$(head -c 500 "$sentinel" 2>/dev/null | tr '\n\r\t' ' ' || true)" + rm -f "$sentinel" + if printf '%s' "$reason" | grep -q '[^[:space:]]'; then + mkdir -p "$(dirname "$log")" + printf '%s\tBYPASS\t%s\treason=%s\n' "$(date -Iseconds)" "$path" "$reason" >> "$log" + exit 0 + fi + printf '%s\n' "[config-protection] .claude/.config-edit-ok had an EMPTY reason -> refused (sentinel consumed). Recreate it with a non-empty reason." >&2 + exit 2 +fi + +cat >&2 </dev/null 2>&1; r=$?; rm -rf "$c"; return "$r"; } + +# --- Guarded quality-gate files -> blocked (exit 2) --- +run "/home/u/Documents/claude/lib/gitflow.sh"; check T1-gitflow "$?" 2 +run "/home/u/.claude/settings.json"; check T2-live-settings "$?" 2 +run "/home/u/Documents/claude/.claude/settings.local.json"; check T3-local-settings "$?" 2 +run "/home/u/Documents/claude/settings.json"; check T4-root-settings "$?" 2 +run "/home/u/Documents/claude/.githooks/pre-commit"; check T5-githook "$?" 2 +run "/home/u/Documents/claude/doctor.sh"; check T6-doctor "$?" 2 +run "/home/u/Documents/claude/.shellcheckrc"; check T7-shellcheckrc "$?" 2 +# self-guard: the hook itself, other hooks, and the test suite are guarded +run "/home/u/Documents/claude/hooks/config-protection.sh"; check T8-self-guard "$?" 2 +run "/home/u/.claude/hooks/session-start.sh"; check T9-deployed-hook "$?" 2 +run "/home/u/Documents/claude/lib/tests/config-protection.test.sh"; check T10-tests-guarded "$?" 2 + +# --- Non-guarded -> allowed (exit 0) --- +run "/home/u/Documents/claude/lib/gitflow-migrate.sh"; check T11-near-miss "$?" 0 +run "/home/u/project/src/app.js"; check T12-code "$?" 0 +run "/home/u/project/settings.json"; check T13-foreign-settings "$?" 0 + +# --- Fail-open on malformed input (no file_path) -> allowed --- +c="$(mktemp -d)"; ( cd "$c" && printf '{}' | bash "$H" ) >/dev/null 2>&1 +check T14-fail-open "$?" 0; rm -rf "$c" + +# --- Sentinel one-shot: non-empty reason -> allow + log + consume; 2nd edit blocked --- +tmp="$(mktemp -d)"; mkdir -p "$tmp/.claude" +printf 'fixing eslint false-positive' > "$tmp/.claude/.config-edit-ok" +( cd "$tmp" && printf '{"tool_name":"Edit","tool_input":{"file_path":"/x/doctor.sh"}}' \ + | HOME="$tmp" bash "$H" ) >/dev/null 2>&1 +check T15-sentinel-allow "$?" 0 +check T15-consumed "$([ -e "$tmp/.claude/.config-edit-ok" ] && echo present || echo gone)" gone +check T15-logged "$(grep -c 'BYPASS.*doctor.sh.*fixing eslint' \ + "$tmp/.claude/logs/config-protection.log" 2>/dev/null)" 1 +( cd "$tmp" && printf '{"tool_name":"Edit","tool_input":{"file_path":"/x/doctor.sh"}}' \ + | HOME="$tmp" bash "$H" ) >/dev/null 2>&1 +check T16-second-blocked "$?" 2 +rm -rf "$tmp" + +# --- Sentinel with EMPTY reason -> refused + consumed --- +tmp="$(mktemp -d)"; mkdir -p "$tmp/.claude"; : > "$tmp/.claude/.config-edit-ok" +( cd "$tmp" && printf '{"tool_name":"Edit","tool_input":{"file_path":"/x/doctor.sh"}}' \ + | HOME="$tmp" bash "$H" ) >/dev/null 2>&1 +check T17-empty-refused "$?" 2 +check T17-consumed "$([ -e "$tmp/.claude/.config-edit-ok" ] && echo present || echo gone)" gone +rm -rf "$tmp" + +printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ] diff --git a/settings.json b/settings.json index 24838b2..fc04c71 100644 --- a/settings.json +++ b/settings.json @@ -245,6 +245,16 @@ "command": "bash ~/.claude/hooks/rtk-rewrite.sh" } ] + }, + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "bash ~/.claude/hooks/config-protection.sh", + "timeout": 5 + } + ] } ], "UserPromptSubmit": [ From 415cde5f5651d4d8385dda12f8bd16023676dfa9 Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Fri, 3 Jul 2026 15:29:20 +0200 Subject: [PATCH 2/2] chore(memory): BDR-047 corrob (Opus 4.8 re-audit) + LRN-090 + journal 2026-07-03 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XpphkdTosUzokBDNG7PToS --- .claude/memory/decisions.md | 12 ++++++++++++ .claude/memory/journal.md | 4 ++++ .claude/memory/learnings.md | 6 ++++++ .claude/tasks/TODO.md | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/.claude/memory/decisions.md b/.claude/memory/decisions.md index f6f149c..6e1cfae 100644 --- a/.claude/memory/decisions.md +++ b/.claude/memory/decisions.md @@ -776,3 +776,15 @@ rules: - **Reference**: read-only clone (scratchpad), 4 parallel analyzer agents + eval-harness spike, this session. No branch on ECC, no import. See [[BDR-045]] (chore/ aiguillage), [[BDR-009]] (caveman registries). +- **Corroboration 2026-07-03** (Opus 4.8 re-audit; repo UNCHANGED — HEAD 81af407 + 2026-06-29, 2232 commits identical, zero commits since 01/07): 6 parallel analyzer + agents re-verified every BDR-047 fact w/ fresh file:line. rules/ inert (paths: 0 + consumers, rules/README.md:333 "cannot distribute rules automatically"); contexts/ + overwrite (the-longform-guide.md:68-74 `--system-prompt`); eval-harness no runner + (/eval absent; gan-harness.sh + skill-improvement/evaluate.js exist but hors-scope, + deliver NEITHER pass@k nor model-upgrade battery); memory auto-capture conflicts + approve-first (continuous-learning-v2 observer-loop.sh:160-164 "Do NOT ask for + permission"); distribution = product scaffolding, N/A. ZERO factual divergence. + ONE scope gap: BDR-047 never opened hooks/ — ECC's only WIRED subsystem. Fruit: + config-protection hook (own idiom, NOT ECC import), shipped + feature/config-protection-hook. Lesson holds + refined by [[LRN-090]]. diff --git a/.claude/memory/journal.md b/.claude/memory/journal.md index 895c878..fdfb1e9 100644 --- a/.claude/memory/journal.md +++ b/.claude/memory/journal.md @@ -308,3 +308,7 @@ rules: - Same branch, 3 doctor false-warns fixed ([[LRN-047]] corrob — a doctor that cries false is ignored): cargo "(RTK unavailable)" → optional info (RTK prebuilt, detect_rtk); check_symlink passes children of dir-level symlinks (hooks/session-start.sh); gstack counts 34 per-skill symlinks not a mythical skills/gstack link (link.sh removes it); token budget vs 200k context window not bogus 11k "session budget" → killed false "92% CRITICAL" (measured ~11.4k [[LRN-088]]; 200k confirmed by user — 1M pin revoked at audit #7, calibrate on default not the exceptional session). - Suites green: gitflow 71/71 (+7), deterministic 13, doc-commit 32, doc-shape 19, reconcile 20, deploy-commit 13, release-candidate 5/5 tag-mode. doctor: 0 false-warn (1 legit survivor = gstack tracks branch=main advisory). shellcheck clean. T12 named to dodge collision with reconcile's own T6c (darwin path, audit #3). - 3 atomic commits (fix gitflow / fix doctor / docs changelog Unreleased) + memory. finish bugfix→develop on GO; user pushes develop. +- ECC 2nd-look (Opus 4.8, 6 agents, repo unchanged since 01/07): all [[BDR-047]] facts corroborated w/ file:line, zero divergence. Scope gap = hooks/ (only wired subsystem) unaudited 01/07 → [[LRN-090]] wired > declarative. +- Shipped config-protection hook (feature/config-protection-hook): PreToolUse blocks Edit/Write to quality-gate files (settings/gitflow/.githooks/doctor/hooks-self/lib-tests/lint). One-shot sentinel .claude/.config-edit-ok (non-empty reason, logged+consumed) — NOT env-var (launch-time = set-and-forget = garde mort). Own idiom, not ECC import. shellcheck clean, test 20/20. +- Live dogfood: hook went active mid-session via symlinked settings (link.sh); v1 (no self-guard) let its OWN edit through → v2 added hooks/*.sh + lib/tests/* self-guard, then blocked the test-file edit; recovered via sentinel. User's self-guard requirement vindicated. +- Next: #2 design-toolchain trigger fix (residual false-fires post-ed2408e, 5× this session). diff --git a/.claude/memory/learnings.md b/.claude/memory/learnings.md index 16e7585..7b8953f 100644 --- a/.claude/memory/learnings.md +++ b/.claude/memory/learnings.md @@ -965,3 +965,9 @@ rules: - **context**: audit 2026-07-02, `lib/gitflow.sh:257` `finish) gitflow_finish "$@"` passed args the function never consulted. Surfaced when a finish "for" one branch merged another (LOT3). [[BLK-015]]. - **future application**: any wrapper/dispatcher forwarding args to a callee that resolves its target from ambient state — either (a) make the callee USE the args as the target, or (b) if the ambient-state contract is deliberate, treat passed args as an ASSERTION and refuse loudly when they disagree with the state. Never let forwarded args be silently dropped: silent-drop = the caller believes they steered, the callee ignored them. Sibling of "presence-flag ≠ capability" [[LRN-087]] — both = a visible signal lying about the real behavior. - **Reference**: `lib/gitflow.sh` gitflow_finish arg-guard, `lib/gitflow-test.sh` T12. [[BLK-015]]. + +## LRN-090 — external-repo audit: open WIRED subsystems before declarative +- **pattern**: auditing external config/framework repo for transferable value → rank subsystems WIRED (executable: hooks/, runners, dispatchers) vs DECLARATIVE (docs, rules/, aspirational frontmatter). Wired > declarative: declarative often inert (ECC rules/ `paths:` = 0 consumers; eval-harness = SKILL.md, no runner — "belle méthodo / vaporware"); wired = a real mechanism worth adapting. +- **context**: ECC 2nd-look 2026-07-03 (Opus 4.8, 6 agents, repo unchanged since 01/07). [[BDR-047]] audit (01/07) inventoried the declarative surface + concluded zero import — right on facts, but hooks/ (ECC's only live subsystem) was OUT of scope and held the sole real adaptation → config-protection PreToolUse guard. +- **future application**: next external-repo value audit → enumerate hooks/, scripts/, runners FIRST; treat rules/docs/SKILL.md as claims to verify ("is it wired?"), not value. Described capability ≠ wired capability. +- **cousin**: [[LRN-087]] presence-flag ≠ capability; [[LRN-089]] forwarded-args silently dropped — same family: a visible signal (a file, a flag, a `paths:`) lying about real behavior. diff --git a/.claude/tasks/TODO.md b/.claude/tasks/TODO.md index 867f37d..81c6fab 100644 --- a/.claude/tasks/TODO.md +++ b/.claude/tasks/TODO.md @@ -1,5 +1,23 @@ # TODO +## 2026-07-03 — config-protection hook (feature/config-protection-hook) +Goal: PreToolUse hook blocks Edit/Write to this config's quality-gate files +(guardrails an agent must not weaken to make an error pass). Adaptation from ECC +second-look (BDR-047 corrob, Opus 4.8 re-audit) — MY idiom (~15-line bash), NOT +ECC's Node dispatcher. Extends config's own doctrine ("backstops déterministes +car l'advisory s'oublie"). Guarded: settings.json (+ .claude/settings*.json), +lib/gitflow.sh, .githooks/*, doctor.sh, lint configs (preemptive, absent today). +Bypass: CONFIG_EDIT_OK="reason" (logged). Mid-session env caveat flagged at gate. + +- [x] hooks/config-protection.sh — case-match guarded path, exit 2 else 0; fail-open +- [x] Guarded: settings.json(+.claude/settings*), lib/gitflow.sh, .githooks/*, doctor.sh, hooks/*.sh (self-guard), lib/tests/* (T6c/LRN-077), lint (preemptive) +- [x] Bypass: one-shot sentinel .claude/.config-edit-ok (non-empty reason, logged+consumed) — NOT env-var (launch-time env = set-and-forget = garde mort) +- [x] lib/tests/config-protection.test.sh — block/allow/self-guard/near-miss/fail-open/sentinel-one-shot/empty-refuse (17 checks) +- [x] settings.json — register PreToolUse matcher Edit|Write|MultiEdit -> hook +- [x] Verify — shellcheck clean + 17/17 PASS + bash -n + bootstrap-safe (hook fires on Edit/Write only, not shell cp/ln) +- [x] GATE passed — guarded list +2 (hooks/, tests/), sentinel over env-var +- [ ] Capitalize (BDR-047 corrob + LRN-090 câblé>déclaratif) + finish this branch only + ## 2026-06-23 — install self-sufficient + gstack on-demand par profil Goal: `make install`/`make plugin`/`make update` installent TOUT sans étape manuelle. Plus le profil-driven gstack on-demand (option 1 user : gstack OFF