diff --git a/README.md b/README.md index 3b9c44f..8d8e556 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ claude-config/ ├── update-all.sh # One-command update for all components ├── Makefile # Unified entry point: make install / doctor / update ├── plugins.lock.json # Version pinning for non-marketplace dependencies -├── hooks/ # Session start, statusline, RTK rewrite, config-protection + design-toolchain guards +├── hooks/ # Session start, statusline, RTK rewrite + design-toolchain guards ├── agents/ # Execution units called by skills (never invoked directly) ├── skills/ # Entry points invoked via /skill-name ├── skills-external/ # Vendored skill packs (gstack submodule + installer-fetched design packs) diff --git a/hooks/config-protection.sh b/hooks/config-protection.sh deleted file mode 100755 index 9d54f0a..0000000 --- a/hooks/config-protection.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/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.global.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" - -# --- T18/T19: payload shapes beyond Edit (locks against future Edit-only narrowing) --- -c="$(mktemp -d)"; ( cd "$c" && printf \ - '{"tool_name":"Write","tool_input":{"file_path":"/x/doctor.sh","content":"x"}}' | bash "$H" ) \ - >/dev/null 2>&1; check T18-write-payload "$?" 2; rm -rf "$c" - -c="$(mktemp -d)"; ( cd "$c" && printf \ - '{"tool_name":"MultiEdit","tool_input":{"file_path":"/x/doctor.sh","edits":[{"old_string":"a","new_string":"b"}]}}' | bash "$H" ) \ - >/dev/null 2>&1; check T19-multiedit-payload "$?" 2; rm -rf "$c" - -# --- T20: sentinel with ONLY whitespace bytes (not literally empty) -> refused + consumed --- -tmp="$(mktemp -d)"; mkdir -p "$tmp/.claude"; printf ' \n\t' > "$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 T20-whitespace-only-refused "$?" 2 -check T20-whitespace-only-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 5495f95..43bdbf8 100644 --- a/settings.json +++ b/settings.json @@ -271,16 +271,6 @@ "command": "bash ~/.claude/hooks/rtk-rewrite.sh" } ] - }, - { - "matcher": "Edit|Write|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "bash ~/.claude/hooks/config-protection.sh", - "timeout": 5 - } - ] } ], "UserPromptSubmit": [