#!/usr/bin/env bash # Claude Code Safety Check # Run: curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/safety-check/check.sh | bash # Verify: curl -fsSL ... | bash -s -- --verify # Public summary only: curl -fsSL ... | bash -s -- --verify --summary-only # Strict CI gate: curl -fsSL ... | bash -s -- --verify --strict # # Audits your Claude Code setup and scores it for safety. # --verify sends test payloads to PreToolUse hooks and checks they actually block. # No hook installation required for the audit. Requires bash and python3. set -euo pipefail print_usage() { cat << 'EOF' Claude Code Safety Check Usage: check.sh [--verify] [--summary-only] [--strict] [--help] Options: --verify Send representative payloads to installed PreToolUse hooks and detect FAIL-OPEN results. --summary-only Print only the bounded copy/paste support summary. --strict With --verify, exit 1 when hook verification fails or is inconclusive. --help Show this help text. EOF } # Parse flags VERIFY_MODE=0 STRICT_MODE=0 SUMMARY_ONLY=0 for arg in "$@"; do case "$arg" in --verify) VERIFY_MODE=1 ;; --summary-only) SUMMARY_ONLY=1 ;; --strict) STRICT_MODE=1 ;; -h|--help) print_usage exit 0 ;; *) printf "Unknown option: %s\n\n" "$arg" >&2 print_usage >&2 exit 2 ;; esac done if [ "$STRICT_MODE" = "1" ] && [ "$VERIFY_MODE" != "1" ]; then printf "Option --strict requires --verify.\n\n" >&2 print_usage >&2 exit 2 fi SUMMARY_ONLY_TMP="" if [ "$SUMMARY_ONLY" = "1" ]; then SUMMARY_ONLY_TMP=$(mktemp "${TMPDIR:-/tmp}/boucle-safety-check-summary-only-XXXXXX") trap '[ -n "$SUMMARY_ONLY_TMP" ] && rm -f "$SUMMARY_ONLY_TMP"' EXIT exec 3>&1 exec >"$SUMMARY_ONLY_TMP" fi CLAUDE_DIR="${HOME}/.claude" SETTINGS_FILE="${CLAUDE_DIR}/settings.json" SCORE=0 MAX_SCORE=0 CHECKS_PASSED=0 CHECKS_TOTAL=0 ISSUES=() FIXES=() SUMMARY_ISSUES=() summary_issue() { local issue="$1" # The full local report can include exact paths. The copy/paste summary is # meant for public support, so redact the two paths most likely to identify # a user or private checkout. if [ -n "${PWD:-}" ] && [ "$PWD" != "/" ]; then issue="${issue//"$PWD"/}" fi if [ -n "${HOME:-}" ] && [ "$HOME" != "/" ]; then issue="${issue//"$HOME"/~}" fi SUMMARY_ISSUES+=("$issue") } have_python3() { [ "${SAFETY_CHECK_TEST_NO_PYTHON3:-0}" = "1" ] && return 1 command -v python3 >/dev/null 2>&1 } # Colors (disabled if not a terminal) if [ -t 1 ]; then GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' else GREEN='' RED='' YELLOW='' BLUE='' BOLD='' DIM='' NC='' fi check() { local name="$1" local weight="$2" local pass="$3" local issue="$4" local fix="${5:-}" MAX_SCORE=$((MAX_SCORE + weight)) CHECKS_TOTAL=$((CHECKS_TOTAL + 1)) if [ "$pass" = "true" ]; then SCORE=$((SCORE + weight)) CHECKS_PASSED=$((CHECKS_PASSED + 1)) printf " ${GREEN}✓${NC} %s ${DIM}(+%d)${NC}\n" "$name" "$weight" else printf " ${RED}✗${NC} %s ${DIM}(0/%d)${NC}\n" "$name" "$weight" ISSUES+=("$issue") if [ -n "$fix" ]; then FIXES+=("$fix") fi fi } # Detect all hooks from both user-level and project-level settings # User-level: ~/.claude/settings.json # Project-level: .claude/settings.json (in current directory) DETECTED_HOOKS="" PROJECT_SETTINGS=".claude/settings.json" ALL_HOOK_CMDS="" # Track all hook commands for inventory HOOK_SOURCES="" # Track where hooks come from find_ancestor_project_settings() { local dir dir="$(pwd -P 2>/dev/null || pwd)" local current_settings_real="" if [ -f "$SETTINGS_FILE" ]; then current_settings_real="$(cd "$(dirname "$SETTINGS_FILE")" 2>/dev/null && printf "%s/%s" "$(pwd -P)" "$(basename "$SETTINGS_FILE")")" fi local parent parent="$(dirname "$dir")" while [ "$parent" != "$dir" ]; do local candidate="$parent/.claude/settings.json" if [ -f "$candidate" ]; then local candidate_real candidate_real="$(cd "$(dirname "$candidate")" 2>/dev/null && printf "%s/%s" "$(pwd -P)" "$(basename "$candidate")")" if [ "$candidate_real" != "$current_settings_real" ]; then printf "%s\n" "$candidate" return 0 fi fi dir="$parent" parent="$(dirname "$dir")" done return 1 } detect_hooks_from() { local file="$1" local source_label="$2" [ -f "$file" ] || return 0 python3 - "$file" "$source_label" << 'PYEOF' import json, sys found = set() all_cmds = [] try: with open(sys.argv[1]) as f: s = json.load(f) source = sys.argv[2] needles = ["bash-guard", "bash_guard", "git-safe", "git_safe", "file-guard", "file_guard", "branch-guard", "branch_guard", "worktree-guard", "worktree_guard", "session-log", "session_log", "read-once", "read_once", "enforce-hooks", "enforce_hooks"] all_hook_types = ["PreToolUse", "PostToolUse", "PostCompact", "SessionStart", "SessionEnd", "Stop", "SubagentStop", "TaskCreated", "WorktreeCreate", "WorktreeRemove", "UserPromptSubmit", "Notification", "PermissionDenied"] for hook_type in all_hook_types: for entry in s.get("hooks", {}).get(hook_type, []): cmds = [] for hook in entry.get("hooks", []): cmd = hook.get("command", "") if cmd: cmds.append(cmd) all_cmds.append(f"{hook_type}:{source}:{cmd}") cmd = entry.get("command", "") if cmd: cmds.append(cmd) all_cmds.append(f"{hook_type}:{source}:{cmd}") for cmd in cmds: for needle in needles: if needle in cmd: found.add(needle.replace("_", "-")) perms = s.get("permissions", {}) if perms.get("allow", []) or perms.get("deny", []): found.add("permissions") except Exception: pass # Output format: HOOKS:space-separated-hooks\nCMDS:newline-separated-cmds print("HOOKS:" + " ".join(found)) for c in all_cmds: print("CMD:" + c) PYEOF } # Merge hooks from both user and project settings _merge_hooks() { local output="" output+=$(detect_hooks_from "$SETTINGS_FILE" "user" 2>/dev/null) output+=$'\n' output+=$(detect_hooks_from "$PROJECT_SETTINGS" "project" 2>/dev/null) local hooks="" local cmds="" while IFS= read -r line; do if [[ "$line" == HOOKS:* ]]; then hooks="$hooks ${line#HOOKS:}" elif [[ "$line" == CMD:* ]]; then cmds="$cmds"$'\n'"${line#CMD:}" fi done <<< "$output" DETECTED_HOOKS="$hooks" ALL_HOOK_CMDS="$cmds" } _merge_hooks has_hook() { echo " $DETECTED_HOOKS " | grep -q " $1 "; } has_hook_event_command() { printf '%s\n' "$ALL_HOOK_CMDS" | grep -q "^$1:.*$2"; } # Check if a specific hook event type (e.g., "Stop") is configured in any settings file has_hook_type() { local event_type="$1" for sf in "$SETTINGS_FILE" "$PROJECT_SETTINGS"; do [ -f "$sf" ] || continue local _hht_result="" _hht_result=$(python3 - "$sf" "$event_type" << 'PYEOF_HHT' import json,sys try: s=json.load(open(sys.argv[1])) hooks=s.get('hooks',{}).get(sys.argv[2],[]) print('yes' if hooks else 'no') except: print('no') PYEOF_HHT ) 2>/dev/null || _hht_result="no" if [ "$_hht_result" = "yes" ]; then return 0 fi done return 1 } echo "" printf "${BOLD}Claude Code Safety Check${NC}\n" echo "━━━━━━━━━━━━━━━━━━━━━━━━" echo "" # === Section 0: Environment Warnings === # These are platform bugs that silently disable hooks - check before anything else WARNINGS=() # IS_DEMO check (claude-code#37780: silently disables all hooks) if [ "${IS_DEMO:-}" = "1" ]; then _WARN="IS_DEMO=1 is set in your environment. This silently disables ALL hooks by suppressing workspace trust. Unset it: unset IS_DEMO (see claude-code#37780)" WARNINGS+=("$_WARN") summary_issue "$_WARN" fi # CLAUDE_CODE_SIMPLE check: disables hooks, MCP tools, attachments, and CLAUDE.md loading entirely if [ -n "${CLAUDE_CODE_SIMPLE:-}" ]; then _WARN="CLAUDE_CODE_SIMPLE is set in your environment. This disables ALL hooks, MCP tools, attachments, and CLAUDE.md file loading. No enforcement rules will fire. Unset it: unset CLAUDE_CODE_SIMPLE (see v2.1.50 changelog)" WARNINGS+=("$_WARN") summary_issue "$_WARN" fi # API key precedence check: shell aliases that unset ANTHROPIC_API_KEY do not # protect Bash-spawned noninteractive `claude -p` subprocesses. if [ -n "${ANTHROPIC_API_KEY:-}" ]; then _WARN="ANTHROPIC_API_KEY is set in your environment. Assistant-launched Bash subprocesses such as 'claude -p' can bypass interactive aliases and use API-key billing instead of subscription/OAuth auth. Start Claude Code from a shell with the key unset, put a real wrapper executable earlier in PATH that unsets billing-sensitive credentials, or block nested claude calls with a PreToolUse Bash rule. (see claude-code#81748)" WARNINGS+=("$_WARN") summary_issue "$_WARN" fi # GIT_INDEX_FILE check (claude-code#38181: corrupts git index when Claude launched from git hooks) if [ -n "${GIT_INDEX_FILE:-}" ]; then WARNINGS+=("GIT_INDEX_FILE is set ($GIT_INDEX_FILE). If Claude was launched from a git hook (post-commit, pre-push, etc.), plugin initialization can corrupt your git index by writing plugin entries into it. Unset this variable before invoking Claude, or run in a separate shell. (see claude-code#38181)") fi # Project-root scope check: Claude Code can treat a subdirectory as the project # root, which skips hooks defined in the repository root's .claude/settings.json. if [ ! -f "$PROJECT_SETTINGS" ]; then _ANCESTOR_PROJECT_SETTINGS="$(find_ancestor_project_settings || true)" if [ -n "$_ANCESTOR_PROJECT_SETTINGS" ]; then _WARN="Ancestor project settings found above the current directory. Run safety-check from the project root that contains .claude/settings.json, or Claude Code may skip root project hooks when launched from this subdirectory." WARNINGS+=("$_WARN") summary_issue "$_WARN" fi fi # JSONC check: settings.json with comments silently breaks hook loading if have_python3; then for _settings_json in "$SETTINGS_FILE" "$PROJECT_SETTINGS"; do [ -f "$_settings_json" ] || continue if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_settings_json" 2>/dev/null; then _WARN="$_settings_json contains JSONC comments or invalid JSON. Hooks may not load. Run any hook installer to auto-fix, or remove // and /* */ comments manually (see claude-code#37540)" WARNINGS+=("$_WARN") summary_issue "$_WARN" fi done fi # Dependency checks: jq is required by 6 of the 7 standalone shell hooks # (bash-guard, git-safe, file-guard, branch-guard, worktree-guard, read-once). if ! command -v jq >/dev/null 2>&1; then WARNINGS+=("jq is not installed. 6 of the 7 standalone shell hooks require jq for JSON parsing and will silently fail without it. Install: brew install jq (macOS), apt install jq (Debian/Ubuntu), or see https://jqlang.github.io/jq/download/") fi # python3 check: needed by enforce-hooks, session-log, and safety-check itself if ! have_python3; then _WARN="python3 is not installed. enforce-hooks and session-log require python3, and safety-check cannot validate settings.json syntax without it." WARNINGS+=("$_WARN") summary_issue "$_WARN" fi # Platform check: Windows hooks have known reliability issues if [[ "${OS:-}" == "Windows_NT" ]] || [[ "$(uname -s 2>/dev/null)" == MINGW* ]] || [[ "$(uname -s 2>/dev/null)" == MSYS* ]]; then WARNINGS+=("Running on Windows. Claude Code hooks fire only ~18% of the time on Windows (see claude-code#37988). Hooks are unreliable on this platform.") WARNINGS+=("Windows: permission path matching in settings.json is case-sensitive, but NTFS is case-insensitive. Deny rules may silently fail if path casing differs from what the model uses. Double-check deny/allow paths match exact casing (see claude-code#40170).") fi # CLI version check: warn about known dangerous versions _claude_version_output() { if have_python3; then python3 - << 'PYEOF_CLAUDE_VERSION' import os import signal import subprocess import sys try: proc = subprocess.Popen( ["claude", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, ) stdout, stderr = proc.communicate(timeout=3) except subprocess.TimeoutExpired: try: os.killpg(proc.pid, signal.SIGKILL) proc.communicate(timeout=1) except Exception: pass sys.exit(124) except Exception: sys.exit(1) output = (stdout or stderr or "").splitlines() if output: print(output[0]) sys.exit(proc.returncode) PYEOF_CLAUDE_VERSION else claude --version 2>/dev/null | head -1 fi } if [ "${SAFETY_CHECK_SKIP_CLAUDE_VERSION:-}" != "1" ] && command -v claude >/dev/null 2>&1; then CLI_VERSION_RAW="" CLI_VERSION_STATUS=0 CLI_VERSION_RAW=$(_claude_version_output 2>/dev/null) || CLI_VERSION_STATUS=$? if [ "$CLI_VERSION_STATUS" -eq 124 ]; then WARNINGS+=("Claude CLI version check timed out after 3 seconds. Skipping version-specific warnings so the audit can continue. Run 'claude --version' separately before trusting version-dependent checks.") fi CLI_VERSION=$(printf "%s\n" "$CLI_VERSION_RAW" | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true) if [ -n "$CLI_VERSION" ]; then CLI_MAJOR_MINOR=$(echo "$CLI_VERSION" | cut -d. -f1-2) CLI_PATCH=$(echo "$CLI_VERSION" | cut -d. -f3) # Only check regressions for the 2.1.x series where they were found if [ "$CLI_MAJOR_MINOR" = "2.1" ]; then # v2.1.78-2.1.84: permissionDecision from PreToolUse hooks silently ignored (claude-code#37597, fixed upstream) if [ "$CLI_PATCH" -ge 78 ] 2>/dev/null && [ "$CLI_PATCH" -le 84 ] 2>/dev/null; then WARNINGS+=("Claude CLI v$CLI_VERSION: permissionDecision responses from PreToolUse hooks may be silently ignored (regression v2.1.78-v2.1.84, see claude-code#37597, now fixed upstream). Hooks using decision:block format still work. Update CLI to resolve.") fi # v2.1.81-2.1.84: crashes when invoked by launchd (claude-code#37878, fixed upstream) if [ "$CLI_PATCH" -ge 81 ] 2>/dev/null && [ "$CLI_PATCH" -le 84 ] 2>/dev/null; then WARNINGS+=("Claude CLI v$CLI_VERSION: crashes when invoked by launchd/cron (regression v2.1.81-v2.1.84, see claude-code#37878, now fixed upstream). Update CLI to resolve.") fi # v2.1.88: deprecated/pulled from npm - custom commands broken + cli.js.map accidentally shipped (claude-code#41497) if [ "$CLI_PATCH" -eq 88 ] 2>/dev/null; then WARNINGS+=("Claude CLI v$CLI_VERSION: this version was deprecated/pulled from npm. Known issues: custom commands in .claude/commands/ are not discovered (claude-code#41497), SessionStart systemMessage display broken (claude-code#41285), custom skills (.claude/skills/) completely non-functional (claude-code#41530). Update to the latest Claude Code release; v2.1.91 restored project command discovery.") fi fi else WARNINGS+=("Claude CLI is installed but 'claude --version' did not return within 3 seconds. Noninteractive runs may hang on CLI prompts; monitor long-running safety checks.") fi fi # deny rules + denyWrite sandbox conflict (claude-code#38375: bwrap failures on Linux) if [ -f "$SETTINGS_FILE" ]; then DENY_DENYWRITE_CONFLICT=$(python3 - "$SETTINGS_FILE" << 'PYEOF' import json, sys try: with open(sys.argv[1]) as f: s = json.load(f) deny_rules = s.get("permissions", {}).get("deny", []) sandbox_deny = s.get("sandbox", {}).get("filesystem", {}).get("denyWrite", []) # Check for explicit filepath deny rules (Write/Edit with paths, not Bash commands) has_filepath_deny = False for rule in deny_rules: r = rule if isinstance(rule, str) else "" # Only Write/Edit deny rules cause bwrap issues (not Bash command patterns) if "(" in r: tool_part = r.split("(")[0].strip() if tool_part in ("Write", "Edit", "MultiEdit"): inner = r.split("(", 1)[1].rstrip(")") if inner.startswith("/") or inner.startswith("~"): has_filepath_deny = True break if has_filepath_deny and sandbox_deny: print("true") else: print("false") except Exception: print("false") PYEOF ) if [ "$DENY_DENYWRITE_CONFLICT" = "true" ]; then WARNINGS+=("Filepath deny rules combined with sandbox denyWrite can cause ALL Bash calls to fail with bwrap errors. bwrap tries to create dummy files for denied filepaths, which conflicts with denyWrite. Use glob patterns in deny rules instead of exact paths. (see claude-code#38375)") fi # Path deny rules don't apply to Bash tool (claude-code#39987) HAS_PATH_DENY=$(python3 - "$SETTINGS_FILE" << 'PYEOF' import json, sys try: with open(sys.argv[1]) as f: s = json.load(f) deny_rules = s.get("permissions", {}).get("deny", []) for rule in deny_rules: r = rule if isinstance(rule, str) else "" if "(" in r: tool_part = r.split("(")[0].strip() if tool_part in ("Read", "Write", "Edit", "Glob", "Grep", "MultiEdit"): print("true") sys.exit(0) print("false") except Exception: print("false") PYEOF ) if [ "$HAS_PATH_DENY" = "true" ] && ! has_hook bash-guard; then WARNINGS+=("Path deny rules only apply to file tools (Read/Write/Edit/Glob/Grep), not to Bash. Claude can still cat, grep, or head denied files via shell commands. Install bash-guard to cover Bash tool access, or use OS-level permissions for true isolation. (see claude-code#39987)") fi fi # bypassPermissions mode instability (claude-code#38372: resets to 'default' in long sessions) if [ -f "$SETTINGS_FILE" ]; then BYPASS_MODE=$(python3 -c " import json,sys try: s=json.load(open(sys.argv[1])) perms=s.get('permissions',{}) mode=( s.get('permissionMode') or s.get('defaultMode') or perms.get('permissionMode') or perms.get('defaultMode') or ('bypassPermissions' if s.get('bypassPermissions') is True else '') or ('bypassPermissions' if perms.get('dangerouslySkipPermissions') is True else '') or '' ) print(mode) except: print('') " "$SETTINGS_FILE" 2>/dev/null) if [ "$BYPASS_MODE" = "bypassPermissions" ]; then WARNINGS+=("permissionMode is set to bypassPermissions. This mode can silently reset to 'default' during long sessions (3+ hours), causing unexpected permission prompts. Consider using PreToolUse hooks for reliable auto-approval instead. (see claude-code#38372)") fi fi # .claude/ sensitive-file prompt cannot be overridden (claude-code#41615) if [ "${BYPASS_MODE:-}" = "bypassPermissions" ]; then WARNINGS+=("Writes to ~/.claude/ trigger a hardcoded sensitive-file prompt that cannot be suppressed by permissions.allow, PreToolUse hooks returning 'allow', bypassPermissions mode, or skipDangerousModePermissionPrompt. Automated sessions (tmux, CI, autonomous loops) that write to ~/.claude/ will stall on an interactive prompt with no workaround. Use Bash tool with echo/cat/jq to write files directly instead of Edit/Write. (see claude-code#41615)") fi # Write permissions don't work outside project directory (claude-code#38391) if [ -f "$SETTINGS_FILE" ]; then HAS_EXTERNAL_WRITE_ALLOW=$(python3 - "$SETTINGS_FILE" << 'PYEOF' import json, sys try: with open(sys.argv[1]) as f: s = json.load(f) allow_rules = s.get("permissions", {}).get("allow", []) for rule in allow_rules: r = rule if isinstance(rule, str) else "" # Write/Edit with absolute path or home-relative path if ("Write(" in r or "Edit(" in r): inner = r.split("(", 1)[1].rstrip(")") if inner.startswith("/") or inner.startswith("~"): print("true") sys.exit(0) print("false") except Exception: print("false") PYEOF ) if [ "$HAS_EXTERNAL_WRITE_ALLOW" = "true" ]; then WARNINGS+=("Write/Edit allow rules with absolute paths outside the project may not auto-approve as expected. Read permissions work with absolute paths, but Write/Edit do not. Use a PreToolUse hook to auto-approve writes to specific external paths. (see claude-code#38391)") fi fi # Colon in filenames breaks permission matching (claude-code#38409: Edit/Write prompt despite allow-list) COLON_FILE=$(find . -maxdepth 5 \ \( -path './.git' -o -path './node_modules' -o -path './.claude' \ -o -path './target' -o -path './dist' -o -path './build' \ -o -path './coverage' -o -path './vendor' -o -path './.cache' \ -o -path './.next' -o -path './.venv' -o -path './venv' \) -prune \ -o -name '*:*' -print -quit 2>/dev/null) if [ -n "$COLON_FILE" ]; then WARNINGS+=("Project contains files with colons in filenames (e.g. $(basename "$COLON_FILE")). Claude Code permission matching breaks on paths containing ':' - Edit/Write will prompt for permission even when allowed or in bypassPermissions mode. Workaround: rename to bracket notation (e.g. [id].vue instead of :id.vue) or use a PreToolUse hook to auto-approve. (see claude-code#38409)") fi # Hooks using permissionDecision "ask" permanently break bypass mode (claude-code#37420) HOOK_DIR="${HOME}/.claude/hooks" if [ -d "$HOOK_DIR" ]; then ASK_HOOKS="" for hookfile in "$HOOK_DIR"/*; do [ -f "$hookfile" ] || continue if grep -qlE 'permissionDecision.*ask|"ask".*permissionDecision' "$hookfile" 2>/dev/null; then ASK_HOOKS="${ASK_HOOKS} $(basename "$hookfile")" fi done if [ -n "$ASK_HOOKS" ]; then WARNINGS+=("Hook(s) use permissionDecision 'ask':${ASK_HOOKS}. This permanently breaks bypass mode for the entire session after the user responds. Use decision:block with a reason instead. (see claude-code#37420)") fi fi if [ -d ".claude/hooks" ]; then ASK_HOOKS="" for hookfile in ".claude/hooks"/*; do [ -f "$hookfile" ] || continue if grep -qlE 'permissionDecision.*ask|"ask".*permissionDecision' "$hookfile" 2>/dev/null; then ASK_HOOKS="${ASK_HOOKS} $(basename "$hookfile")" fi done if [ -n "$ASK_HOOKS" ]; then WARNINGS+=("Project hook(s) use permissionDecision 'ask':${ASK_HOOKS}. This permanently breaks bypass mode for the entire session. Use decision:block with a reason instead. (see claude-code#37420)") fi fi # MCP PreToolUse ask/deny enforcement is platform-unreliable even when direct # hook stdin tests pass (claude-code#33106, claude-code#81569). detect_mcp_pretooluse_matchers() { local file="$1" local source_label="$2" [ -f "$file" ] || return 0 python3 - "$file" "$source_label" << 'PYEOF' import json, sys try: with open(sys.argv[1]) as f: settings = json.load(f) except Exception: sys.exit(0) source = sys.argv[2] matches = [] for entry in settings.get("hooks", {}).get("PreToolUse", []): matcher = str(entry.get("matcher", "")) commands = [str(hook.get("command", "")) for hook in entry.get("hooks", [])] commands.append(str(entry.get("command", ""))) haystack = " ".join([matcher] + commands) if "mcp__" in haystack or "MCP" in haystack: matches.append(matcher or "") if matches: print(f"{source}:{', '.join(matches[:3])}") PYEOF } MCP_HOOK_MATCHERS="" MCP_USER_MATCHERS=$(detect_mcp_pretooluse_matchers "$SETTINGS_FILE" "user" 2>/dev/null || true) MCP_PROJECT_MATCHERS=$(detect_mcp_pretooluse_matchers "$PROJECT_SETTINGS" "project" 2>/dev/null || true) if [ -n "$MCP_USER_MATCHERS" ]; then MCP_HOOK_MATCHERS="$MCP_HOOK_MATCHERS $MCP_USER_MATCHERS" fi if [ -n "$MCP_PROJECT_MATCHERS" ]; then MCP_HOOK_MATCHERS="$MCP_HOOK_MATCHERS $MCP_PROJECT_MATCHERS" fi if [ -n "$MCP_HOOK_MATCHERS" ]; then _WARN="PreToolUse hooks target MCP tools:${MCP_HOOK_MATCHERS}. Claude Code has reported gaps where permissionDecision ask/deny is ignored for real MCP tool calls even when direct hook stdin tests pass. Use MCP server-side controls or managed-settings disallowedTools for sensitive MCP writes. (see claude-code#33106, claude-code#81569)" WARNINGS+=("$_WARN") summary_issue "$_WARN" fi # Async PreToolUse decisions cannot reliably block Agent or Task dispatch. # Keep this detector narrow so ordinary Bash and other tool matchers are not # reported as affected by the Agent/Task limitation. detect_agent_task_pretooluse_matchers() { local file="$1" local source_label="$2" [ -f "$file" ] || return 0 python3 - "$file" "$source_label" << 'PYEOF_AGENT_TASK' import json import re import sys try: with open(sys.argv[1]) as f: settings = json.load(f) except Exception: sys.exit(0) source = sys.argv[2] matches = [] def sanitize_matcher(value): # Keep the matcher readable while preventing terminal control characters # from affecting warning or summary output. return "".join( ch if (ord(ch) >= 0x20 and ord(ch) != 0x7F) else f"\\x{ord(ch):02x}" for ch in value ) for entry in settings.get("hooks", {}).get("PreToolUse", []): raw_matcher = str(entry.get("matcher", "")) if re.search(r"(?i)(?") if matches: print(f"{source}:{', '.join(matches[:3])}") PYEOF_AGENT_TASK } AGENT_TASK_HOOK_MATCHERS="" AGENT_TASK_USER_MATCHERS=$(detect_agent_task_pretooluse_matchers "$SETTINGS_FILE" "user" 2>/dev/null || true) AGENT_TASK_PROJECT_MATCHERS=$(detect_agent_task_pretooluse_matchers "$PROJECT_SETTINGS" "project" 2>/dev/null || true) if [ -n "$AGENT_TASK_USER_MATCHERS" ]; then AGENT_TASK_HOOK_MATCHERS="$AGENT_TASK_HOOK_MATCHERS $AGENT_TASK_USER_MATCHERS" fi if [ -n "$AGENT_TASK_PROJECT_MATCHERS" ]; then AGENT_TASK_HOOK_MATCHERS="$AGENT_TASK_HOOK_MATCHERS $AGENT_TASK_PROJECT_MATCHERS" fi if [ -n "$AGENT_TASK_HOOK_MATCHERS" ]; then _WARN="PreToolUse hooks target Agent or Task:${AGENT_TASK_HOOK_MATCHERS}. Claude Code has reported that async PreToolUse decisions can arrive after Agent or Task dispatch, so they may not reliably block spawned work. Treat this as an advisory and use the known limitation guidance for enforcement boundaries. (see https://framework.boucle.sh/limitations.html#pretooluse-agent-task-async-results-can-arrive-after-dispatch)" WARNINGS+=("$_WARN") summary_issue "$_WARN" fi # Hooks using exit code 2 for deny may be silently ignored (claude-code#37210) # Exit 2 can be treated as a hook crash, causing Claude to proceed despite the deny. # Correct pattern: exit 0 with hookSpecificOutput JSON on stdout. # Current format: {"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"..."}} # Deprecated format (still works): {"decision":"block","reason":"..."} for hookdir in "${HOME}/.claude/hooks" ".claude/hooks"; do [ -d "$hookdir" ] || continue EXIT2_HOOKS="" for hookfile in "$hookdir"/*; do [ -f "$hookfile" ] || continue if grep -qlE 'exit\s+2' "$hookfile" 2>/dev/null; then EXIT2_HOOKS="${EXIT2_HOOKS} $(basename "$hookfile")" fi done if [ -n "$EXIT2_HOOKS" ]; then scope="Hook(s)" [ "$hookdir" = ".claude/hooks" ] && scope="Project hook(s)" WARNINGS+=("${scope} use exit code 2 for deny:${EXIT2_HOOKS}. Exit 2 is treated as a hook crash and may be silently ignored, especially for Edit/Write tools. For PreToolUse, use exit 0 with {\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"...\"}} JSON on stdout instead. For other hook events, use that event's supported block or continue response; do not rely on exit 2. (see claude-code#37210)") fi done # Spaces in working directory path break hooks (claude-code#39478) case "$PWD" in *" "*) WARNINGS+=("Working directory contains spaces: $PWD. Claude Code may pass unquoted paths to hooks, causing parse errors. Move your project to a path without spaces if hooks misbehave. (see claude-code#39478)") ;; esac _UNQUOTED_PROJECT_DIR_HOOKS=$(python3 - "$ALL_HOOK_CMDS" << 'PYEOF_UNQUOTED_PROJECT_DIR' import sys commands = sys.argv[1].splitlines() bad = [] for line in commands: if not line: continue try: hook_type, source, command = line.split(":", 2) except ValueError: continue quote = None escaped = False i = 0 while i < len(command): ch = command[i] if escaped: escaped = False i += 1 continue if ch == "\\": escaped = True i += 1 continue if ch == "'" and quote != '"': quote = None if quote == "'" else "'" i += 1 continue if ch == '"' and quote != "'": quote = None if quote == '"' else '"' i += 1 continue if command.startswith("$CLAUDE_PROJECT_DIR", i) or command.startswith("${CLAUDE_PROJECT_DIR}", i): if quote != '"': bad.append(f"{hook_type}/{source}") break i += 1 for item in sorted(set(bad)): print(item) PYEOF_UNQUOTED_PROJECT_DIR ) if [ -n "$_UNQUOTED_PROJECT_DIR_HOOKS" ]; then _UNQUOTED_PROJECT_DIR_COUNT=$(printf "%s\n" "$_UNQUOTED_PROJECT_DIR_HOOKS" | awk 'NF { count++ } END { print count + 0 }') WARNINGS+=("${_UNQUOTED_PROJECT_DIR_COUNT} hook command(s) reference CLAUDE_PROJECT_DIR without double quotes:${_UNQUOTED_PROJECT_DIR_HOOKS//$'\n'/, }. If the project path contains spaces, Claude Code can word-split the hook path and fail open with hook_non_blocking_error exit 127. Quote the variable inside settings.json, for example \"\$CLAUDE_PROJECT_DIR/.claude/hooks/hook.sh\". (see claude-code#81458)") fi # Spaces in HOME path break hook command invocation (claude-code#40084) # When the user profile path contains spaces (e.g. /Users/Lea Chan/), hook commands # that reference $HOME or CLAUDE_PLUGIN_ROOT get word-split by bash, causing: # bash: /c/Users/Lea: No such file or directory # This affects ALL hooks - both plugin hooks and settings.json hooks. case "$HOME" in *" "*) WARNINGS+=("Home directory contains spaces: $HOME. Hook commands that include your home path will fail because Claude Code's hook runner word-splits the path at spaces. Affected: all hooks in ~/.claude/. Workaround: create a symlink from a space-free path (e.g. ln -s \"$HOME\" /opt/claude-home) and update hook command paths, or use PowerShell hooks on Windows. (see claude-code#40084)") ;; esac # Stop hooks blocking parallel sessions (claude-code#39530) _STOP_LIFECYCLE_HOOKS=$(python3 - "$ALL_HOOK_CMDS" << 'PYEOF_STOP_LIFECYCLE' import sys events = {"Stop", "PostToolUse", "SessionEnd"} details = [] for line in sys.argv[1].splitlines(): if not line: continue parts = line.split(":", 2) if len(parts) != 3: continue hook_type, source, command = parts if hook_type not in events: continue command = " ".join(command.split()) if len(command) > 160: command = command[:157] + "..." details.append(f"{hook_type}/{source}: {command}") for item in sorted(set(details)): print(item) PYEOF_STOP_LIFECYCLE ) if [ -n "$_STOP_LIFECYCLE_HOOKS" ]; then _STOP_LIFECYCLE_COUNT=$(printf "%s\n" "$_STOP_LIFECYCLE_HOOKS" | awk 'NF { count++ } END { print count + 0 }') WARNINGS+=("${_STOP_LIFECYCLE_COUNT} Stop/PostToolUse lifecycle hook command(s) detected: ${_STOP_LIFECYCLE_HOOKS//$'\n'/, }. Stop-style hooks fire across ALL parallel Claude sessions sharing this settings file, not just the session that triggered them. If you run multiple Claude instances, a stop hook from one session can affect others. Use separate project directories or check \$CLAUDE_SESSION_ID in your hook. (see claude-code#39530)") fi # SessionEnd hooks killed before completion (claude-code#41577) for _se_cfg in "$SETTINGS_FILE" "$PROJECT_SETTINGS"; do [ -f "$_se_cfg" ] || continue _HAS_SESSION_END=$(python3 -c " import json,sys try: s=json.load(open(sys.argv[1])) se = s.get('hooks',{}).get('SessionEnd',[]) print('true' if se else 'false') except: print('false') " "$_se_cfg" 2>/dev/null) if [ "$_HAS_SESSION_END" = "true" ]; then WARNINGS+=("SessionEnd hooks detected. Claude Code exits the process without waiting for SessionEnd hooks to complete - any async work (API calls, LLM summarization, network requests) is killed mid-execution. Workaround: detach heavy work into a background process with nohup/disown so it survives parent exit, then exit 0 immediately. (see claude-code#41577)") break fi done # Hooks using updatedInput on tool calls with reported rewrite gaps # (claude-code#39814, claude-code#79321, claude-code#81340) for hookdir in "${HOME}/.claude/hooks" ".claude/hooks"; do [ -d "$hookdir" ] || continue UPDATEDINPUT_HOOKS="" for hookfile in "$hookdir"/*; do [ -f "$hookfile" ] || continue if grep -qlE 'updatedInput' "$hookfile" 2>/dev/null; then UPDATEDINPUT_HOOKS="${UPDATEDINPUT_HOOKS} $(basename "$hookfile")" fi done if [ -n "$UPDATEDINPUT_HOOKS" ]; then scope="Hook(s)" [ "$hookdir" = ".claude/hooks" ] && scope="Project hook(s)" WARNINGS+=("${scope} use updatedInput:${UPDATEDINPUT_HOOKS}. The updatedInput field is silently ignored for Agent tool calls, and recent reports show Bash command rewrites can also be dropped while permissionDecision is still honored. Use decision:block to reject unsafe Agent prompts or Bash commands rather than relying on transparent rewrites. (see claude-code#39814, claude-code#79321, claude-code#81340)") fi done # Worktree isolation silent failure warning (claude-code#39886) if has_hook worktree-guard; then WARNINGS+=("Worktree isolation can silently fail. The Agent tool's isolation:worktree option may run the agent in the main repo instead of a worktree, with worktreePath:done and worktreeBranch:undefined. worktree-guard protects ExitWorktree but cannot detect failed worktree creation. Verify agent results if you rely on branch isolation. (see claude-code#39886)") fi # Hook stdout corrupts worktree paths (claude-code#40262) # Any hook returning JSON on stdout can corrupt the worktree path when Agent uses isolation:"worktree". # The JSON gets concatenated into the path instead of being consumed by the hook protocol. HOOK_COUNT=0 for hookdir in "${HOME}/.claude/hooks" ".claude/hooks"; do [ -d "$hookdir" ] || continue for hookfile in "$hookdir"/*; do [ -f "$hookfile" ] && HOOK_COUNT=$((HOOK_COUNT + 1)) done done # Also count hooks from settings.json if [ -f "$SETTINGS_FILE" ]; then SETTINGS_HOOK_COUNT=$(python3 -c " import json,sys try: s=json.load(open(sys.argv[1])) c=0 for ht in s.get('hooks',{}): for entry in s['hooks'][ht]: for h in entry.get('hooks',[]): if h.get('command',''): c+=1 if entry.get('command',''): c+=1 print(c) except: print(0) " "$SETTINGS_FILE" 2>/dev/null) HOOK_COUNT=$((HOOK_COUNT + ${SETTINGS_HOOK_COUNT:-0})) fi if [ "$HOOK_COUNT" -gt 0 ]; then WARNINGS+=("Hooks and worktree isolation are incompatible on v2.1.86+. Hook stdout JSON is concatenated into the worktree path instead of being consumed by the hook protocol, producing paths like /project/{\"continue\":true}. If you spawn agents with isolation:worktree, expect Path does not exist errors. No workaround except disabling hooks before worktree agent calls. (see claude-code#40262)") WARNINGS+=("Hook enforcement does not work in subagents. Agent-spawned subagents have been reported to skip PreToolUse Bash hooks entirely, and in other cases hooks fire but exit-code/block decisions are silently ignored. A command blocked in the parent session can succeed in a subagent. Treat hook-based enforcement as parent-session-only until your exact subagent workflow is verified. (see claude-code#40580, claude-code#78970)") fi # additionalDirectories leak across projects (claude-code#40606) if [ -f "$SETTINGS_FILE" ]; then ADDITIONAL_DIRS=$(python3 -c " import json,sys try: s=json.load(open(sys.argv[1])) dirs=s.get('additionalDirectories',[]) if dirs: print('\n'.join(dirs)) except: pass " "$SETTINGS_FILE" 2>/dev/null) if [ -n "$ADDITIONAL_DIRS" ]; then DIR_COUNT=$(echo "$ADDITIONAL_DIRS" | wc -l | tr -d ' ') WARNINGS+=("Global settings.json contains ${DIR_COUNT} additionalDirectories entry/entries. These directories are shared across ALL projects - approving file access outside the working directory in one project makes those paths available in every other project, and subagents will search them. Additionally, 'always allow' directory access is flaky and may not persist across sessions - Claude will re-prompt for access despite prior approval (claude-code#41579). Review and remove entries not needed for the current project. (see claude-code#40606, claude-code#41579)") fi fi # Glob-special characters in project path break Read permissions (claude-code#40613) CURRENT_DIR="$(pwd)" if echo "$CURRENT_DIR" | grep -qE '[{}]' || echo "$CURRENT_DIR" | grep -q '\[' || echo "$CURRENT_DIR" | grep -q '\]'; then WARNINGS+=("Your project path contains glob metacharacters ({, }, [, or ]). Claude Code's Read permission matching interprets these as glob patterns instead of literal characters, causing permission failures. Rename the directory to remove these characters. PreToolUse hooks use exact string matching and are unaffected. (see claude-code#40613)") fi # Plan mode does not deactivate bypass permissions (claude-code#40623) if [ -f "$SETTINGS_FILE" ]; then HAS_BYPASS=$(python3 -c " import json,sys try: s=json.load(open(sys.argv[1])) if s.get('bypassPermissions'): print('yes') except: pass " "$SETTINGS_FILE" 2>/dev/null) if [ "$HAS_BYPASS" = "yes" ]; then WARNINGS+=("bypassPermissions is enabled globally. Note: entering plan mode does NOT deactivate bypass permissions - the model can execute write operations during what you expect to be a read-only analysis phase (claude-code#41545 confirms this with --dangerously-skip-permissions). PreToolUse hooks fire regardless of both modes and are the only reliable constraint during plan+bypass overlap. (see claude-code#40623, claude-code#41545)") fi fi # Non-enabled marketplace plugins still fire hooks (claude-code#40013) # Installed-but-not-enabled plugins have their hooks loaded and executed anyway. MARKETPLACE_DIR="${HOME}/.claude/plugins/marketplaces" if [ -d "$MARKETPLACE_DIR" ]; then ORPHAN_PLUGINS="" ENABLED_PLUGINS=$(python3 -c " import json,sys,os try: sf = os.path.expanduser('~/.claude/settings.json') s = json.load(open(sf)) for p in s.get('enabledPlugins', []): print(p.split('/')[-1] if '/' in p else p) except: pass " 2>/dev/null) for plugin_dir in "$MARKETPLACE_DIR"/*/plugins/*/; do [ -d "$plugin_dir" ] || continue plugin_name=$(basename "$plugin_dir") if ! echo "$ENABLED_PLUGINS" | grep -qF "$plugin_name" 2>/dev/null; then # Check if it actually has hooks if [ -d "${plugin_dir}hooks" ] || ls "${plugin_dir}"*.sh >/dev/null 2>&1; then ORPHAN_PLUGINS="${ORPHAN_PLUGINS} ${plugin_name}" fi fi done if [ -n "$ORPHAN_PLUGINS" ]; then WARNINGS+=("Non-enabled marketplace plugins with hooks detected:${ORPHAN_PLUGINS}. These plugins are NOT in your enabledPlugins list but their hooks still fire on every session. Remove unwanted plugin directories from ${MARKETPLACE_DIR} to prevent unauthorized hook execution. (see claude-code#40013)") fi # Marketplace plugins with hooks installed silently (claude-code#40036) # Even enabled plugins may have hooks the user never consented to. PLUGINS_WITH_HOOKS="" for plugin_dir in "$MARKETPLACE_DIR"/*/plugins/*/; do [ -d "$plugin_dir" ] || continue plugin_name=$(basename "$plugin_dir") if [ -d "${plugin_dir}hooks" ]; then # Count hook files hook_count=$(find "${plugin_dir}hooks" -type f 2>/dev/null | wc -l | tr -d ' ') if [ "$hook_count" -gt 0 ]; then PLUGINS_WITH_HOOKS="${PLUGINS_WITH_HOOKS} ${plugin_name}(${hook_count} hooks)" fi fi done if [ -n "$PLUGINS_WITH_HOOKS" ]; then WARNINGS+=("Marketplace plugins with executable hooks:${PLUGINS_WITH_HOOKS}. The /plugin install flow does not disclose that these plugins include hooks. These hooks run on every session with your full user privileges and no consent prompt. Inspect hook contents: ls ~/.claude/plugins/marketplaces/*/plugins/*/hooks/ (see claude-code#40036)") fi fi # Stop hooks do not fire in VSCode extension (claude-code#40029) if has_hook_type "Stop"; then WARNINGS+=("Stop hooks are configured but do not fire in the VSCode extension. If you use Claude Code in VSCode, your Stop hooks are silently skipped. PreToolUse, PostToolUse, and SessionStart hooks work in both CLI and VSCode. (see claude-code#40029)") fi # UserPromptSubmit hooks can silently fail to deliver systemMessage (claude-code#40647) if has_hook_type "UserPromptSubmit"; then WARNINGS+=("UserPromptSubmit hooks are configured but their systemMessage delivery is intermittently unreliable. The hook command fires and returns valid JSON, but the injected systemMessage may not reach the model. For safety enforcement, prefer PreToolUse hooks which gate on the decision field rather than systemMessage injection. (see claude-code#40647)") fi # Stop hooks receive stale transcript data (claude-code#40655) if has_hook_type "Stop"; then WARNINGS+=("Stop hooks fire before the transcript JSONL file is fully flushed to disk. Any Stop hook that reads the transcript to inspect the assistant's last output will see stale data missing the final content blocks (15-44ms race window, 64% failure rate measured). This affects completion detection, audit logging, and post-session analysis. No reliable workaround exists. (see claude-code#40655)") fi # WorktreeCreate/WorktreeRemove hooks ignored by EnterWorktree tool (claude-code#36205) if has_hook_type "WorktreeCreate" || has_hook_type "WorktreeRemove"; then WARNINGS+=("WorktreeCreate/WorktreeRemove hooks are configured but the EnterWorktree tool does not fire them. Worktree hooks only fire when worktree isolation is triggered by the system (background agents), not when the model explicitly calls EnterWorktree. Custom VCS setup in worktree hooks may not execute. (see claude-code#36205)") fi # WorktreeCreate hooks cause indefinite hang (claude-code#41614) if has_hook_type "WorktreeCreate"; then WARNINGS+=("WorktreeCreate hooks cause Claude Code to hang indefinitely when using 'claude -w'. Any WorktreeCreate hook, even a trivial 'echo ok', causes the session to freeze after the hook completes. The hook executes and returns but Claude Code never proceeds. This affects all hook commands, not just complex ones. Remove WorktreeCreate hooks if you need 'claude -w' to function. (see claude-code#41614)") fi # TaskCreated hooks - available since v2.1.84 if has_hook_type "TaskCreated"; then WARNINGS+=("TaskCreated hooks are configured. These fire when a task is created via TaskCreate. Note: TaskCreated hooks cannot block task creation (decision field is ignored). They are observe-only, useful for logging or notifications but not enforcement.") fi # SubagentStop hooks - subagent-scoped lifecycle if has_hook_type "SubagentStop"; then WARNINGS+=("SubagentStop hooks are configured. These fire when a spawned subagent completes, providing the last_assistant_message field. Note: background agents may not inherit all hook configurations from the parent session. (see claude-code#40818)") fi # PermissionDenied hooks - available since v2.1.89 (claude-code#41261) # Fires after auto mode classifier denials. Return {retry: true} to tell model it can retry. if has_hook_type "PermissionDenied"; then WARNINGS+=("PermissionDenied hooks are configured. This event fires after auto mode classifier denials (available since v2.1.89). Return {\"retry\": true} in hookSpecificOutput to tell the model it can retry the denied operation. Without retry, denied operations are not retried. (see claude-code#41261)") fi # SessionStart hook systemMessage not rendered in terminal (claude-code#41285) # The hook fires and additionalContext is injected, but systemMessage visual output is missing. if has_hook_type "SessionStart"; then WARNINGS+=("SessionStart hooks are configured but their systemMessage field is no longer displayed in the terminal (v2.1.88 regression). The hook runs and additionalContext is still injected into model context, but the visual feedback that used to appear (e.g. 'SessionStart:startup says: ...') is silently dropped. If your SessionStart hook uses systemMessage for operator notifications or session identification, the output will not be visible. (see claude-code#41285)") fi # SessionStart/UserPromptSubmit hooks fire before project directory exists (claude-code#41310) # On first-ever session in a project, the hooks fire before ~/.claude/projects// is created. # Hooks that derive file paths from transcript_path will fail because the parent directory doesn't exist yet. if has_hook_type "SessionStart" || has_hook_type "UserPromptSubmit"; then WARNINGS+=("SessionStart and UserPromptSubmit hooks can fire before the project directory (~/.claude/projects//) exists on first-ever sessions in a project. If your hooks write files derived from transcript_path, they will fail because the parent directory has not been created yet. Workaround: add 'mkdir -p' for any transcript_path-derived paths before writing. (see claude-code#41310)") fi # Model self-execution after long sessions (claude-code#41307) # In long sessions, after task-notification, the model can hallucinate 'Human:' text and execute it. # This is a model-level issue - hooks cannot distinguish real vs hallucinated user requests. # Only warn when bypass mode is active (indicates unattended/autonomous sessions where this is a real risk). if [ "${BYPASS_MODE:-}" = "bypassPermissions" ]; then WARNINGS+=("In long sessions, the model can hallucinate 'Human:' prefixed text after task-notification delivery and then execute it as if it were a real user request, causing unauthorized tool calls. Hooks cannot distinguish these from real user requests because the tool calls themselves are genuine - only the trigger is hallucinated. Mitigation: use session time limits, avoid very long unattended sessions. (see claude-code#41307)") fi # PreToolUse hooks on EnterPlanMode - hook output deprioritized (claude-code#41051) _check_planmode_matcher() { local sf="$1" [ -f "$sf" ] || return 1 python3 - "$sf" << 'PYEOF_PM' import json,sys try: s=json.load(open(sys.argv[1])) found = False for entry in s.get('hooks',{}).get('PreToolUse',[]): m = entry.get('matcher','') if 'EnterPlanMode' in m: found = True break print('yes' if found else 'no') except Exception: print('no') PYEOF_PM } for sf in "$SETTINGS_FILE" "$PROJECT_SETTINGS"; do _PM_RESULT=$(_check_planmode_matcher "$sf" 2>/dev/null) || _PM_RESULT="no" if [ "$_PM_RESULT" = "yes" ]; then WARNINGS+=("PreToolUse hook targets EnterPlanMode. Hook output injected via system-reminder is deprioritized by plan mode's own detailed system prompt, which arrives in the same turn. The model will follow plan mode's Phase 1-5 workflow and ignore the hook's instructions. Use decision:block to gate entry, or move logic to a separate event. (see claude-code#41051)") break fi done # bypassPermissions in settings.local.json is silently ignored (claude-code#40014) # Check both user-level and project-level settings.local.json for SETTINGS_LOCAL in "${HOME}/.claude/settings.local.json" ".claude/settings.local.json"; do if [ -f "$SETTINGS_LOCAL" ]; then HAS_BYPASS_LOCAL=$(python3 -c " import json,sys try: s=json.load(open(sys.argv[1])) pm = s.get('permission-mode','') or s.get('permissions',{}).get('permissionMode','') or s.get('permissions',{}).get('dangerouslySkipPermissions','') if pm: print('true') else: print('false') except: print('false') " "$SETTINGS_LOCAL" 2>/dev/null) if [ "$HAS_BYPASS_LOCAL" = "true" ]; then WARNINGS+=("settings.local.json sets permission/bypass configuration, but these settings are silently ignored. The only working method to enable bypass mode is the CLI flag --dangerously-skip-permissions. Remove the setting to avoid confusion. (see claude-code#40014)") fi fi # settings.local.json permissions desync after Edit tool modifies it (claude-code#41259) # When Claude's Edit tool modifies settings.local.json, in-memory permission state desyncs from disk. # Allow rules stop being respected; user is repeatedly prompted even though the file is correct. if [ -f "$SETTINGS_LOCAL" ]; then SETTINGS_LOCAL_EDIT_RISK=$(python3 -c " import json,sys try: s=json.load(open(sys.argv[1])) perms = s.get('permissions',{}) allow = perms.get('allow',{}) or perms.get('allow',[]) deny = perms.get('deny',{}) or perms.get('deny',[]) if allow or deny: print('true') else: print('false') except: print('false') " "$SETTINGS_LOCAL" 2>/dev/null) if [ "$SETTINGS_LOCAL_EDIT_RISK" = "true" ]; then WARNINGS+=("settings.local.json contains permission rules. If Claude's Edit tool modifies this file during a session, in-memory permissions desync from disk: allow rules stop working and the user is repeatedly prompted. Let Claude Code manage this file through its own permission prompt mechanism, or restart the session after any manual edit. (see claude-code#41259)") fi fi done # end settings.local.json loop (user-level + project-level) # Sandbox allowedDomains HTTP bypass (claude-code#40213) # allowedDomains only filters HTTPS CONNECT - plain HTTP passes through unfiltered. if [ -f "$SETTINGS_FILE" ]; then HAS_ALLOWED_DOMAINS=$(python3 - "$SETTINGS_FILE" << 'PYEOF_AD' import json, sys try: s = json.load(open(sys.argv[1])) sandbox = s.get("sandbox", {}) network = sandbox.get("network", {}) domains = network.get("allowedDomains", []) if domains: print("true") else: print("false") except: print("false") PYEOF_AD ) if [ "$HAS_ALLOWED_DOMAINS" = "true" ]; then WARNINGS+=("sandbox.network.allowedDomains is configured but only filters HTTPS traffic. Plain HTTP requests (curl http://...) bypass domain filtering entirely. A prompt injection payload can exfiltrate data over HTTP even with allowedDomains set. Use bash-guard to detect outbound HTTP requests, or configure OS-level firewall rules for defense in depth. (see claude-code#40213)") fi fi # Supply-chain: detect suspicious project-level .claude/settings.json (claude-code#38319) # A malicious repo can include .claude/settings.json that adds hooks or loosens permissions. # Project settings merge with user settings - they can ADD hooks and allow rules. if [ -f "$PROJECT_SETTINGS" ]; then SUPPLY_CHAIN_FLAGS=$(python3 - "$PROJECT_SETTINGS" << 'PYEOF_SC' import json, sys, re flags = [] try: with open(sys.argv[1]) as f: s = json.load(f) perms = s.get("permissions", {}) # Flag 1: Project sets bypassPermissions if perms.get("permissionMode") == "bypassPermissions": flags.append("sets permissionMode to bypassPermissions (all tool calls auto-approved)") # Flag 2: Overly broad allow rules for rule in perms.get("allow", []): r = rule if isinstance(rule, str) else "" if r in ("Bash", "Bash(*)", "Bash(**)", "*"): flags.append(f"allow rule '{r}' permits all Bash commands") elif re.search(r'sudo|rm\s+-rf|curl.*\|.*bash|wget.*\|.*bash|chmod\s+777|mkfs|dd\s+if=', r, re.I): flags.append(f"allow rule contains dangerous command: {r[:60]}") # Flag 3: Project spoofs companyAnnouncements (claude-code#39998) announcements = s.get("companyAnnouncements") if announcements: flags.append(f"sets companyAnnouncements - messages will appear as if from your company (social engineering risk)") # Flag 4: Project hooks that reference external URLs or suspicious commands all_hook_types = ["PreToolUse", "PostToolUse", "PostCompact", "SessionStart", "SessionEnd", "Stop", "SubagentStop", "TaskCreated", "WorktreeCreate", "WorktreeRemove", "UserPromptSubmit", "Notification", "PermissionDenied"] for hook_type in all_hook_types: for entry in s.get("hooks", {}).get(hook_type, []): cmds = [] for hook in entry.get("hooks", []): cmd = hook.get("command", "") if cmd: cmds.append(cmd) cmd = entry.get("command", "") if cmd: cmds.append(cmd) for cmd in cmds: if re.search(r'https?://', cmd): flags.append(f"project hook contacts external URL: {cmd[:80]}") if re.search(r'base64\s+-d|eval\s|python.*-c|node\s+-e', cmd): flags.append(f"project hook runs inline code: {cmd[:80]}") except Exception: pass for f in flags: print(f) PYEOF_SC ) if [ -n "$SUPPLY_CHAIN_FLAGS" ]; then WARNINGS+=("PROJECT SUPPLY-CHAIN RISK: This repo contains .claude/settings.json with suspicious entries:") while IFS= read -r flag; do [ -z "$flag" ] && continue WARNINGS+=(" -> $flag") done <<< "$SUPPLY_CHAIN_FLAGS" WARNINGS+=("Review .claude/settings.json carefully. Project settings merge with your user settings and can add hooks or allow rules. (see claude-code#38319)") fi fi # Hooks using bare "decision":"warn" without hookSpecificOutput are silently dropped (claude-code#40380) for hookdir in "${HOME}/.claude/hooks" ".claude/hooks"; do [ -d "$hookdir" ] || continue WARN_HOOKS="" for hookfile in "$hookdir"/*; do [ -f "$hookfile" ] || continue # Detect hooks that output decision:warn but don't use hookSpecificOutput if grep -qlE '"decision".*"warn"|"warn".*"decision"' "$hookfile" 2>/dev/null; then if ! grep -qlE 'hookSpecificOutput' "$hookfile" 2>/dev/null; then WARN_HOOKS="${WARN_HOOKS} $(basename "$hookfile")" fi fi done if [ -n "$WARN_HOOKS" ]; then scope="Hook(s)" [ "$hookdir" = ".claude/hooks" ] && scope="Project hook(s)" WARNINGS+=("${scope} use bare decision:warn without hookSpecificOutput:${WARN_HOOKS}. Warn-level hook responses without hookSpecificOutput are silently dropped - neither the user nor the model sees the warning. Use hookSpecificOutput with permissionDecision:allow and additionalContext instead. (see claude-code#40380)") fi done # Also check settings.json hook commands for the same pattern for _cfg in "$SETTINGS_FILE" "$PROJECT_SETTINGS"; do [ -f "$_cfg" ] || continue _BARE_WARN=$(python3 - "$_cfg" << 'PYEOF_WARN' import json, sys, re try: s = json.load(open(sys.argv[1])) hooks = s.get("hooks", {}) for hook_type in hooks: for entry in hooks[hook_type]: cmd = "" for h in entry.get("hooks", []): cmd += h.get("command", "") + " " cmd += entry.get("command", "") if re.search(r'"decision".*"warn"|"warn".*"decision"', cmd): if "hookSpecificOutput" not in cmd: print("true") sys.exit(0) print("false") except Exception: print("false") PYEOF_WARN ) if [ "$_BARE_WARN" = "true" ]; then WARNINGS+=("Settings hook commands reference decision:warn without hookSpecificOutput. Warn-level responses are silently dropped. Use hookSpecificOutput with permissionDecision:allow and additionalContext to surface warnings to the model. (see claude-code#40380)") break fi done # Hooks using deprecated decision:block format without hookSpecificOutput (claude-code#15486) # The old format {"decision":"block","reason":"..."} still works but is deprecated. # New format: {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}} # Migration avoids breakage if the old format is removed in a future CLI version. _hook_contains_bare_decision_block() { python3 - "$1" << 'PYEOF_BARE_BLOCK_FILE' import re import sys try: text = open(sys.argv[1], encoding="utf-8", errors="replace").read() except Exception: sys.exit(1) pattern = re.compile("\"decision\"\\s*:\\s*\"block\"|decision\\s*:\\s*[\"" + chr(39) + "]block[\"" + chr(39) + "]|\"block\".*\"decision\"", re.S) sys.exit(0 if pattern.search(text) else 1) PYEOF_BARE_BLOCK_FILE } for hookdir in "${HOME}/.claude/hooks" ".claude/hooks"; do [ -d "$hookdir" ] || continue DEPRECATED_HOOKS="" for hookfile in "$hookdir"/*; do [ -f "$hookfile" ] || continue # Detect hooks that output decision:block but don't use hookSpecificOutput. # Include JavaScript object literals such as { decision: 'block', reason }. if _hook_contains_bare_decision_block "$hookfile"; then if ! grep -qlE 'hookSpecificOutput' "$hookfile" 2>/dev/null; then DEPRECATED_HOOKS="${DEPRECATED_HOOKS} $(basename "$hookfile")" fi fi done if [ -n "$DEPRECATED_HOOKS" ]; then scope="Hook(s)" [ "$hookdir" = ".claude/hooks" ] && scope="Project hook(s)" WARNINGS+=("${scope} use deprecated decision:block format:${DEPRECATED_HOOKS}. This format still works but is deprecated. Migrate to {\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"...\"}} to avoid breakage in future CLI versions. (see claude-code#15486)") fi done # Also check settings.json hook commands for deprecated decision:block for _cfg in "$SETTINGS_FILE" "$PROJECT_SETTINGS"; do [ -f "$_cfg" ] || continue _DEPRECATED_BLOCK=$(python3 - "$_cfg" << 'PYEOF_DEPRECATED' import json, sys, re try: s = json.load(open(sys.argv[1])) hooks = s.get("hooks", {}) pattern = re.compile("\"decision\"\\s*:\\s*\"block\"|decision\\s*:\\s*[\"" + chr(39) + "]block[\"" + chr(39) + "]|\"block\".*\"decision\"") for hook_type in hooks: for entry in hooks[hook_type]: cmd = "" for h in entry.get("hooks", []): cmd += h.get("command", "") + " " cmd += entry.get("command", "") if pattern.search(cmd): if "hookSpecificOutput" not in cmd: print("true") sys.exit(0) print("false") except Exception: print("false") PYEOF_DEPRECATED ) if [ "$_DEPRECATED_BLOCK" = "true" ]; then WARNINGS+=("Settings hook commands use deprecated decision:block format. Migrate to hookSpecificOutput with hookEventName:PreToolUse and permissionDecision:deny. The old format still works but may be removed in a future CLI version. (see claude-code#15486)") break fi done # Hook-specific JSON output missing hookEventName fails Claude Code validation. for hookdir in "${HOME}/.claude/hooks" ".claude/hooks"; do [ -d "$hookdir" ] || continue MISSING_EVENT_HOOKS="" for hookfile in "$hookdir"/*; do [ -f "$hookfile" ] || continue if grep -qlE 'hookSpecificOutput' "$hookfile" 2>/dev/null && \ grep -qlE 'permissionDecision' "$hookfile" 2>/dev/null && \ ! grep -qlE 'hookEventName' "$hookfile" 2>/dev/null; then MISSING_EVENT_HOOKS="${MISSING_EVENT_HOOKS} $(basename "$hookfile")" fi done if [ -n "$MISSING_EVENT_HOOKS" ]; then scope="Hook(s)" [ "$hookdir" = ".claude/hooks" ] && scope="Project hook(s)" WARNINGS+=("${scope} emit hookSpecificOutput without hookEventName:${MISSING_EVENT_HOOKS}. Claude Code validates hookSpecificOutput as event-specific JSON; include \"hookEventName\":\"PreToolUse\" with permissionDecision outputs.") fi done for _cfg in "$SETTINGS_FILE" "$PROJECT_SETTINGS"; do [ -f "$_cfg" ] || continue _MISSING_EVENT=$(python3 - "$_cfg" << 'PYEOF_MISSING_EVENT' import json, sys try: s = json.load(open(sys.argv[1])) hooks = s.get("hooks", {}) for hook_type in hooks: for entry in hooks[hook_type]: cmd = entry.get("command", "") for h in entry.get("hooks", []): cmd += " " + h.get("command", "") if "hookSpecificOutput" in cmd and "permissionDecision" in cmd and "hookEventName" not in cmd: print("true") sys.exit(0) print("false") except Exception: print("false") PYEOF_MISSING_EVENT ) if [ "$_MISSING_EVENT" = "true" ]; then WARNINGS+=("Settings hook commands emit hookSpecificOutput without hookEventName. Claude Code validates hookSpecificOutput as event-specific JSON; include \"hookEventName\":\"PreToolUse\" with permissionDecision outputs.") break fi done # Session-level permission caching bypasses allow list in sandbox mode (claude-code#40384) # Approving one git commit auto-approves ALL subsequent git commits without prompting. if [ -f "$SETTINGS_FILE" ]; then _HAS_SANDBOX=$(python3 -c " import json,sys try: s=json.load(open(sys.argv[1])) print('true' if s.get('sandbox',{}).get('enabled') else 'false') except: print('false') " "$SETTINGS_FILE" 2>/dev/null) if [ "$_HAS_SANDBOX" = "true" ]; then WARNINGS+=("Sandbox mode enabled. Session-level permission caching may bypass your allow list: approving one 'git commit' auto-approves ALL subsequent 'git commit' calls without re-prompting. If you expect per-invocation prompts for sensitive commands, use a PreToolUse hook to enforce them. (see claude-code#40384)") fi fi # Skills/workflows can override CLAUDE.md directives (claude-code#41437) # When a skill instructs the model (e.g. "commit all files"), CLAUDE.md rules like "don't commit X" # are deprioritized because skill instructions arrive as tool_result context in the same turn. _SKILL_DIRS="" [ -d "${HOME}/.claude/skills" ] && _SKILL_DIRS="user:${HOME}/.claude/skills" [ -d ".claude/skills" ] && _SKILL_DIRS="${_SKILL_DIRS} project:.claude/skills" # Check for plugin skills _PLUGIN_SKILL_COUNT=0 if [ -d "${HOME}/.claude/plugins" ]; then _PLUGIN_SKILL_COUNT=$(find "${HOME}/.claude/plugins" -type d -name "skills" 2>/dev/null | wc -l | tr -d ' ') fi _HAS_SKILLS=false if [ -n "$_SKILL_DIRS" ] || [ "$_PLUGIN_SKILL_COUNT" -gt 0 ]; then _HAS_SKILLS=true fi if [ "$_HAS_SKILLS" = "true" ]; then # Check if CLAUDE.md exists and has restrictions/rules _HAS_CLAUDEMD_RULES=false for _cmd_file in "CLAUDE.md" "${HOME}/.claude/CLAUDE.md"; do if [ -f "$_cmd_file" ]; then if grep -qiE 'never|don.t|do not|must not|forbidden|prohibit|block|deny|restrict|@enforced' "$_cmd_file" 2>/dev/null; then _HAS_CLAUDEMD_RULES=true break fi fi done if [ "$_HAS_CLAUDEMD_RULES" = "true" ]; then _SKILL_SOURCES="" [ -d "${HOME}/.claude/skills" ] && _SKILL_SOURCES="user-level skills ($(ls "${HOME}/.claude/skills" 2>/dev/null | wc -l | tr -d ' ') items)" [ -d ".claude/skills" ] && _SKILL_SOURCES="${_SKILL_SOURCES:+${_SKILL_SOURCES}, }project-level skills ($(ls ".claude/skills" 2>/dev/null | wc -l | tr -d ' ') items)" [ "$_PLUGIN_SKILL_COUNT" -gt 0 ] && _SKILL_SOURCES="${_SKILL_SOURCES:+${_SKILL_SOURCES}, }plugin skills (${_PLUGIN_SKILL_COUNT} plugin skill dirs)" WARNINGS+=("Skills/workflows can override CLAUDE.md directives. Active sources: ${_SKILL_SOURCES}. When a skill instructs the model to perform an action (e.g. 'commit all changed files'), CLAUDE.md rules prohibiting that action are deprioritized because skill instructions arrive as high-priority tool_result context. Use enforce-hooks (PreToolUse) to gate the specific operations you need protected - hooks enforce at the tool-call level and cannot be overridden by prompt content. (see claude-code#41437)") fi fi # Non-interactive sessions permanently stuck on usage limit (claude-code#41502, #41503) # In headless/remote-control/--print mode, hitting the usage limit shows a prompt # that cannot be answered - the session hangs indefinitely with no programmatic recovery. # Check if running non-interactively (common for autonomous agents, CI, cron) if [ ! -t 0 ] || [ -n "${CI:-}" ] || [ -n "${CLAUDE_NON_INTERACTIVE:-}" ]; then WARNINGS+=("Running non-interactively (stdin is not a terminal). If a Claude session hits a usage limit, it will prompt for confirmation but cannot receive input - the session hangs permanently. There is no programmatic workaround. Set session time limits or monitor for stuck processes. (see claude-code#41502, claude-code#41503)") fi EARLY_WARNING_COUNT=${#WARNINGS[@]} if [ ${#WARNINGS[@]} -gt 0 ]; then printf "${RED}${BOLD}⚠ Environment Warnings${NC}\n" for warn in "${WARNINGS[@]}"; do printf " ${RED}!${NC} %s\n" "$warn" done echo "" fi # === Section 1: Basic Setup === printf "${BLUE}Setup${NC}\n" check "Claude Code installed" 5 \ "$(command -v claude >/dev/null 2>&1 && echo true || echo false)" \ "Claude Code CLI not found" \ "Install: https://docs.anthropic.com/en/docs/claude-code" check "Settings file exists" 5 \ "$(if [ -f "$SETTINGS_FILE" ] || [ -f "$PROJECT_SETTINGS" ]; then echo true; else echo false; fi)" \ "No global or project settings.json - Claude Code may be using defaults" \ "" # === Section 2: Destructive Command Protection === echo "" printf "${BLUE}Destructive Command Protection${NC}\n" check "bash-guard (blocks rm -rf /, sudo, curl|bash)" 20 \ "$(has_hook bash-guard && echo true || echo false)" \ "No bash-guard: Claude can run rm -rf /, sudo, curl|bash, and other dangerous commands" \ "curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/bash-guard/install.sh | bash" check "git-safe (blocks force push, hard reset)" 15 \ "$(has_hook git-safe && echo true || echo false)" \ "No git-safe: Claude can force-push, hard-reset, and destroy git history" \ "curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/git-safe/install.sh | bash" # === Section 3: File Protection === echo "" printf "${BLUE}File Protection${NC}\n" check "file-guard (protects .env, secrets, keys)" 15 \ "$(has_hook file-guard && echo true || echo false)" \ "No file-guard: Claude can read/modify .env, private keys, and credential files" \ "curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/file-guard/install.sh | bash" check "branch-guard (prevents commits to main)" 10 \ "$(has_hook branch-guard && echo true || echo false)" \ "No branch-guard: Claude can commit directly to main/master/production" \ "curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/branch-guard/install.sh | bash" check "worktree-guard (prevents data loss on worktree exit)" 10 \ "$(has_hook worktree-guard && echo true || echo false)" \ "No worktree-guard: exiting a worktree silently deletes branches with unmerged commits (anthropics/claude-code#38287)" \ "curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/worktree-guard/install.sh | bash" # === Section 4: Observability === echo "" printf "${BLUE}Observability${NC}\n" check "session-log (audit trail of all actions)" 15 \ "$(has_hook session-log && echo true || echo false)" \ "No session-log: no record of what Claude did in each session" \ "curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/session-log/install.sh | bash" # === Section 5: Efficiency === echo "" printf "${BLUE}Efficiency${NC}\n" check "read-once (prevents redundant file reads)" 5 \ "$(has_hook read-once && echo true || echo false)" \ "No read-once: Claude re-reads files it already has, wasting tokens" \ "curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/read-once/install.sh | bash" if has_hook read-once; then check "read-once PostCompact cache reset" 2 \ "$(has_hook_event_command "PostCompact" "read-once.*compact" && echo true || echo false)" \ "read-once installed without PostCompact cache reset: after compaction, first re-reads may still be blocked by stale session cache" \ "curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/install.sh | bash -s -- upgrade" fi # === Section 6: Built-in protections === echo "" printf "${BLUE}Built-in Settings${NC}\n" check "Permission rules configured" 5 \ "$(has_hook permissions && echo true || echo false)" \ "No permission allow/deny rules in settings.json" \ "See: https://docs.anthropic.com/en/docs/claude-code/settings" # Warn if deny rules exist without bash-guard: deny rules only match the first # line/command, so multi-line scripts and compound commands bypass them entirely. # See: claude-code#38119, claude-code#37662 if has_hook permissions && ! has_hook bash-guard; then HAS_DENY=false if [ -f "$SETTINGS_FILE" ]; then HAS_DENY=$(python3 -c " import json,sys try: s=json.load(open(sys.argv[1])) d=s.get('permissions',{}).get('deny',[]) print('true' if d else 'false') except: print('false') " "$SETTINGS_FILE" 2>/dev/null) fi if [ "$HAS_DENY" = "true" ]; then printf "\n ${YELLOW}⚠${NC} Deny rules alone are bypassable: pipe chains (find | xargs rm),\n" printf " compound statements (cmd1 && cmd2), and multi-line commands bypass\n" printf " pattern matching. Deny rules only match the full command string, not\n" printf " individual segments. bash-guard parses each segment independently.\n" printf " Note: hooks 'if' conditions were fixed upstream (late March 2026) to\n" printf " match compound commands and env-var prefixes, so hooks now fire correctly\n" printf " for these patterns. The gap is in deny rules, not hooks.\n" printf " ${DIM}See: claude-code#41559, claude-code#38119, claude-code#37662${NC}\n" ISSUES+=("Deny rules without bash-guard: deny patterns only match the full command string. Pipe chains (find | xargs rm), compound commands (echo ok && rm -rf /), multi-line scripts, and leading comments all bypass deny rule matching. Hooks 'if' conditions now handle compound commands (upstream fix, late March 2026), so hooks fire correctly. The gap is in deny rules only. bash-guard parses pipe segments and compound chains independently. See claude-code#41559.") fi fi # Glob wildcard injection in allow rules (claude-code#40344) # Allow rules with * match across shell operators (&&, ;, ||, |), enabling command injection if has_hook permissions; then GLOB_INJECTION_RULES=$(python3 - "$SETTINGS_FILE" << 'PYEOF_GLOB' import json, sys, re try: with open(sys.argv[1]) as f: s = json.load(f) allow_rules = s.get("permissions", {}).get("allow", []) flagged = [] for rule in allow_rules: r = rule if isinstance(rule, str) else "" # Flag Bash allow rules containing * wildcards if r.startswith("Bash(") and "*" in r: flagged.append(r) for r in flagged: print(r) except Exception: pass PYEOF_GLOB ) if [ -n "$GLOB_INJECTION_RULES" ]; then printf "\n ${RED}⚠${NC} ${BOLD}SECURITY: Glob wildcards in Bash allow rules enable command injection${NC}\n" printf " The * wildcard matches shell operators (&&, ;, ||, |), so an allow\n" printf " rule like Bash(git -C * status) also silently allows:\n" printf " git -C /repo && rm -rf / && git status\n" printf " Affected rules:\n" while IFS= read -r rule; do [ -z "$rule" ] && continue printf " ${RED}→${NC} %s\n" "$rule" done <<< "$GLOB_INJECTION_RULES" printf " Fix: use a PreToolUse hook to parse commands structurally instead\n" printf " of relying on glob-based allow rules.\n" printf " ${DIM}See: claude-code#40344${NC}\n" ISSUES+=("SECURITY: Bash allow rules with * wildcards are vulnerable to command injection. The * matches across shell operators (&&, ;, |), so any command containing the allowed prefix can chain arbitrary commands. Use PreToolUse hooks (like bash-guard) for structural command validation instead of glob-based allow rules.") fi fi # "Confirm each change individually" overridden by allow permissions (claude-code#41551) # When the user exits plan mode and selects "confirm each change individually", the # confirmation prompt is silently skipped if the tool is in permissions.allow. if has_hook permissions; then BROAD_ALLOWS=$(python3 - "$SETTINGS_FILE" << 'PYEOF_BROAD_ALLOW' import json, sys try: with open(sys.argv[1]) as f: s = json.load(f) allow_rules = s.get("permissions", {}).get("allow", []) broad = [] for rule in allow_rules: r = rule if isinstance(rule, str) else "" # Flag broad tool allows that override per-session confirmation bare = r.strip() if bare in ("Edit", "Write", "Bash", "MultiEdit", "Edit(*)", "Write(*)", "Bash(*)"): broad.append(bare) for r in broad: print(r) except Exception: pass PYEOF_BROAD_ALLOW ) if [ -n "$BROAD_ALLOWS" ]; then printf "\n ${YELLOW}⚠${NC} Broad allow rules override per-session \"confirm individually\" choice:\n" while IFS= read -r rule; do [ -z "$rule" ] && continue printf " ${YELLOW}→${NC} %s\n" "$rule" done <<< "$BROAD_ALLOWS" printf " When exiting plan mode and selecting 'confirm each change individually',\n" printf " these allow rules silently skip the confirmation prompt.\n" printf " Fix: remove broad allows and use hooks for enforcement instead.\n" printf " ${DIM}See: claude-code#41551${NC}\n" ISSUES+=("Broad allow rules (${BROAD_ALLOWS//$'\n'/, }) override per-session 'confirm each change individually' choice. The user's explicit per-session choice is silently overridden by persistent allow rules. Remove broad allows and use PreToolUse hooks for enforcement. See claude-code#41551.") fi fi # bypassPermissions on agents ignores project allowlist (claude-code#40343) if [ -f "$SETTINGS_FILE" ]; then HAS_BYPASS_AGENTS=$(python3 - "$SETTINGS_FILE" << 'PYEOF_BPA' import json, sys try: with open(sys.argv[1]) as f: s = json.load(f) agents = s.get("agents", {}) for name, config in agents.items(): if isinstance(config, dict) and config.get("mode") == "bypassPermissions": print(name) except Exception: pass PYEOF_BPA ) if [ -n "$HAS_BYPASS_AGENTS" ]; then printf "\n ${YELLOW}⚠${NC} Agents with bypassPermissions ignore project-level allowlists entirely.\n" printf " These agents can execute any tool (Write, Edit, rm, git) with no checks:\n" while IFS= read -r agent_name; do [ -z "$agent_name" ] && continue printf " ${YELLOW}→${NC} %s\n" "$agent_name" done <<< "$HAS_BYPASS_AGENTS" printf " bypassPermissions skips per-tool prompts AND the project allowlist.\n" printf " Use PreToolUse hooks for covered tool-call enforcement instead of relying on agent mode.\n" printf " Verify hooks after install; hook coverage is not a sandbox.\n" printf " ${DIM}See: claude-code#40343${NC}\n" fi fi # Plugin operations silently erase settings.json keys (claude-code#41137, #40714, #30109) # plugin install/update/marketplace-add rewrites settings.json, dropping unrecognized keys # including mcpServers, permissions, and custom configuration. if [ -f "$SETTINGS_FILE" ]; then HAS_MCP_OR_CUSTOM=$(python3 - "$SETTINGS_FILE" << 'PYEOF_SETTINGS_INTEGRITY' import json, sys try: with open(sys.argv[1]) as f: s = json.load(f) plugin_keys = {"enabledPlugins", "extraKnownMarketplaces", "plugins"} custom_keys = set(s.keys()) - plugin_keys important = [k for k in custom_keys if k in ("mcpServers", "permissions", "hooks")] if important: print(",".join(important)) except Exception: pass PYEOF_SETTINGS_INTEGRITY ) if [ -n "$HAS_MCP_OR_CUSTOM" ]; then WARNINGS+=("Your global settings.json contains keys (${HAS_MCP_OR_CUSTOM}) that plugin operations (install, update, marketplace add) will silently erase. Back up ~/.claude/settings.json before running plugin commands. The install.sh backup/restore commands can help. (see claude-code#41137, #40714)") fi fi # Background agent control: #41461 # Only warn when bypass mode is active (automated sessions are more likely to spawn background agents unsupervised). if [ "${BYPASS_MODE:-}" = "bypassPermissions" ]; then WARNINGS+=("Background agents spawned via the Agent tool cannot be reliably stopped by the user. In one reported case, 14 parallel agents wrote to the same file and consumed ~1.4M tokens (\$55-106) before the session could be terminated. If you use Agent tool with run_in_background, monitor token usage closely. There is no built-in kill mechanism for spawned agents. (see claude-code#41461)") fi # cleanupPeriodDays setting ignored: #41458 if [ -f "$SETTINGS_FILE" ]; then HAS_CLEANUP=$(python3 -c " import json, sys try: with open('$SETTINGS_FILE') as f: s = json.load(f) v = s.get('cleanupPeriodDays') if v is not None and int(v) > 365: print('true') else: print('false') except Exception: print('false') " 2>/dev/null || echo "false") if [ "$HAS_CLEANUP" = "true" ]; then WARNINGS+=("cleanupPeriodDays is set to a high value in settings.json but may be silently ignored. One user lost 490 session files despite setting cleanupPeriodDays:99999 since January. If you rely on session persistence, back up ~/.claude/projects/ independently. (see claude-code#41458)") fi fi # Bundled ripgrep execute permission: #41463 if [ "$(uname)" = "Linux" ]; then # Find the bundled rg binary if it exists BUNDLED_RG="" for rg_candidate in "$HOME/.claude/local/rg" "$HOME/.claude/bin/rg" /usr/local/lib/node_modules/@anthropic-ai/claude-code/vendor/rg; do if [ -f "$rg_candidate" ]; then BUNDLED_RG="$rg_candidate" break fi done if [ -n "$BUNDLED_RG" ] && [ ! -x "$BUNDLED_RG" ]; then WARNINGS+=("Bundled ripgrep binary ($BUNDLED_RG) is missing execute permission. This silently breaks all user-defined slash commands in ~/.claude/commands/ and may affect file search. Fix: chmod +x \"$BUNDLED_RG\" (see claude-code#41463)") fi fi # Symlinked .claude/ directories: #41451 if [ -L ".claude" ] || [ -L ".claude/commands" ] || [ -L ".claude/hooks" ]; then SYMLINK_TARGETS="" [ -L ".claude" ] && SYMLINK_TARGETS=" .claude" [ -L ".claude/commands" ] && SYMLINK_TARGETS="${SYMLINK_TARGETS} .claude/commands" [ -L ".claude/hooks" ] && SYMLINK_TARGETS="${SYMLINK_TARGETS} .claude/hooks" WARNINGS+=("Symlinked .claude directories detected:${SYMLINK_TARGETS}. On Linux, slash commands from symlinked .claude/commands/ are not discovered (regression). Hooks and skills may also fail to load if .claude/ itself is a symlink. Workaround: copy files instead of symlinking, or use a post-checkout git hook to sync. (see claude-code#41451)") fi # Project-scoped plugins active outside projectPath: #41523 if [ -f "${HOME}/.claude/plugins.json" ]; then LEAKED_PLUGINS=$(python3 -c " import json, sys try: with open(sys.argv[1]) as f: plugins = json.load(f) leaked = [] for p in plugins if isinstance(plugins, list) else []: scope = p.get('scope', '') path = p.get('projectPath', '') if scope in ('project', 'local') and path: leaked.append(p.get('name', path)) if leaked: print(','.join(leaked[:5])) except Exception: pass " "${HOME}/.claude/plugins.json" 2>/dev/null) if [ -n "$LEAKED_PLUGINS" ]; then WARNINGS+=("Project-scoped plugins detected: ${LEAKED_PLUGINS}. These plugins fire in ALL directories, not just their declared projectPath. A plugin meant for one project runs its hooks and tools everywhere. Audit ~/.claude/plugins.json and remove unwanted entries. (see claude-code#41523)") fi fi # User+project plugin enablement can produce only a project-scoped install record: #81706 INSTALLED_PLUGINS_FILE="${HOME}/.claude/plugins/installed_plugins.json" if [ -f "$INSTALLED_PLUGINS_FILE" ]; then PLUGIN_SCOPE_ISSUES=$(python3 - "$SETTINGS_FILE" "$PROJECT_SETTINGS" "$INSTALLED_PLUGINS_FILE" << 'PYEOF' 2>/dev/null import json import sys def read_json(path): try: with open(path, encoding="utf-8") as fh: return json.load(fh) except Exception: return {} def enabled_refs(path): settings = read_json(path) raw = settings.get("enabledPlugins", {}) refs = set() if isinstance(raw, dict): for key, value in raw.items(): if value is not False: refs.add(str(key)) elif isinstance(raw, list): refs.update(str(item) for item in raw) return refs user_settings, project_settings, installed_path = sys.argv[1:4] user_enabled = enabled_refs(user_settings) project_enabled = enabled_refs(project_settings) both_enabled = user_enabled & project_enabled installed = read_json(installed_path) if not isinstance(installed, dict): installed = {} missing_user_scope = [] invalid_project_scope = [] for ref, records in installed.items(): if not isinstance(records, list): continue has_user_record = any( isinstance(record, dict) and record.get("scope") == "user" for record in records ) if ref in both_enabled and records and not has_user_record: missing_user_scope.append(ref) for record in records: if ( isinstance(record, dict) and record.get("scope") == "project" and not record.get("projectPath") ): invalid_project_scope.append(ref) break if missing_user_scope: print("missing_user_scope:" + ",".join(sorted(missing_user_scope)[:5])) if invalid_project_scope: print("invalid_project_scope:" + ",".join(sorted(invalid_project_scope)[:5])) PYEOF ) while IFS= read -r plugin_scope_line; do case "$plugin_scope_line" in missing_user_scope:*) PLUGIN_REFS=${plugin_scope_line#missing_user_scope:} WARNINGS+=("Plugins enabled at both user and project scope lack user-scope install records: ${PLUGIN_REFS}. They can appear enabled globally while working only in one project, so any hooks or policy shipped by those plugins may be absent elsewhere. Reinstall at user scope or keep separate user and project install records. (see claude-code#81706)") ;; invalid_project_scope:*) PLUGIN_REFS=${plugin_scope_line#invalid_project_scope:} WARNINGS+=("Plugin install records with scope=project but no projectPath detected: ${PLUGIN_REFS}. Claude Code cannot reliably match these records to a project, and loader behavior may depend on record order. Audit ~/.claude/plugins/installed_plugins.json before trusting plugin hooks. (see claude-code#81706)") ;; esac done <<< "$PLUGIN_SCOPE_ISSUES" fi # MCP tool calls silently rejected by parameter value: #41528 # Universal warning - affects anyone using MCP tools in allow lists if [ -f "$SETTINGS_FILE" ]; then HAS_MCP_ALLOW=$(python3 -c " import json, sys try: with open(sys.argv[1]) as f: s = json.load(f) allows = s.get('permissions', {}).get('allow', []) has_mcp = any('mcp__' in str(a) for a in allows) print('true' if has_mcp else 'false') except Exception: print('false') " "$SETTINGS_FILE" 2>/dev/null || echo "false") if [ "$HAS_MCP_ALLOW" = "true" ]; then WARNINGS+=("MCP tools in your permission allow list may be silently rejected for certain parameter values. The same tool works with some parameters but is blocked without a prompt for others. If MCP calls fail silently, check if the parameter value triggers stricter matching. (see claude-code#41528)") fi fi # Display any warnings added after the initial display if [ ${#WARNINGS[@]} -gt ${EARLY_WARNING_COUNT} ]; then if [ ${EARLY_WARNING_COUNT} -eq 0 ]; then printf "${RED}${BOLD}⚠ Environment Warnings${NC}\n" else printf "${RED}${BOLD}⚠ Additional Warnings${NC}\n" fi for ((i=EARLY_WARNING_COUNT; i<${#WARNINGS[@]}; i++)); do printf " ${RED}!${NC} %s\n" "${WARNINGS[$i]}" done echo "" fi # === Section 7: Rule Enforcement === echo "" printf "${BLUE}Rule Enforcement${NC}\n" # Check for enforce-hooks in user-level settings ENFORCE_IN_USER=$(has_hook enforce-hooks && echo true || echo false) # Also check project-level: .claude/hooks/enforce-hooks.py or referenced in .claude/settings.json ENFORCE_IN_PROJECT=false if [ -f ".claude/hooks/enforce-hooks.py" ]; then ENFORCE_IN_PROJECT=true elif [ -f ".claude/settings.json" ]; then ENFORCE_IN_PROJECT=$(python3 - ".claude/settings.json" << 'PYEOF2' import json, sys try: with open(sys.argv[1]) as f: s = json.load(f) for ht in ["PreToolUse", "PostToolUse"]: for e in s.get("hooks", {}).get(ht, []): for h in e.get("hooks", []): if "enforce" in h.get("command", ""): print("true"); sys.exit(0) if "enforce" in e.get("command", ""): print("true"); sys.exit(0) except Exception: pass print("false") PYEOF2 ) fi ENFORCE_FOUND=false if [ "$ENFORCE_IN_USER" = "true" ] || [ "$ENFORCE_IN_PROJECT" = "true" ]; then ENFORCE_FOUND=true fi check "enforce-hooks (turns CLAUDE.md rules into hooks)" 10 \ "$ENFORCE_FOUND" \ "No enforce-hooks: CLAUDE.md rules are suggestions that degrade as context grows" \ "curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/enforce/install.sh | bash" # Check for @enforced rules in CLAUDE.md if [ -f "CLAUDE.md" ]; then ENFORCED_COUNT=$(grep -c '@enforced' CLAUDE.md 2>/dev/null || true) ENFORCED_COUNT=${ENFORCED_COUNT:-0} if [ "$ENFORCED_COUNT" -gt 0 ]; then check "CLAUDE.md has @enforced rules ($ENFORCED_COUNT found)" 5 \ "true" \ "" \ "" else check "CLAUDE.md has @enforced rules" 5 \ "false" \ "CLAUDE.md exists but has no @enforced rules. Rules without @enforced are advisory only." \ "Add @enforced to section headings in CLAUDE.md, e.g.: ## Safety @enforced" fi else check "CLAUDE.md has @enforced rules" 5 \ "false" \ "No CLAUDE.md found in current directory. Create one with @enforced rules for deterministic enforcement." \ "" fi # === Section 7b: CLAUDE.md Rule Coverage === # Scan CLAUDE.md for enforceable rules and show which hooks cover them if [ -f "CLAUDE.md" ]; then RULE_SUGGESTIONS=() # Check for file protection rules not covered by file-guard if ! has_hook file-guard; then if grep -qiE '\.env|secret|credential|private.?key|api.?key|\.pem|\.key|token' CLAUDE.md 2>/dev/null; then RULE_SUGGESTIONS+=("file-guard - your CLAUDE.md mentions sensitive files (.env, keys, credentials)") fi fi # Check for git safety rules not covered by git-safe if ! has_hook git-safe; then if grep -qiE 'force.?push|reset.?--hard|checkout\s*\.|clean\s+-f|branch\s+-[dD]|push.?--delete' CLAUDE.md 2>/dev/null; then RULE_SUGGESTIONS+=("git-safe - your CLAUDE.md mentions destructive git operations") fi fi # Check for command safety rules not covered by bash-guard if ! has_hook bash-guard; then if grep -qiE 'rm\s+-rf|sudo|curl.*bash|drop\s+(table|database)|dangerous|destructive' CLAUDE.md 2>/dev/null; then RULE_SUGGESTIONS+=("bash-guard - your CLAUDE.md mentions dangerous commands") fi fi # Check for branch protection rules not covered by branch-guard if ! has_hook branch-guard; then if grep -qiE 'feature.?branch|never.*commit.*main|no.*direct.*commit|protected.?branch' CLAUDE.md 2>/dev/null; then RULE_SUGGESTIONS+=("branch-guard - your CLAUDE.md mentions branch protection") fi fi # Check for worktree safety rules not covered by worktree-guard if ! has_hook worktree-guard; then if grep -qiE 'worktree|merge.*before.*exit|push.*before.*exit|unmerged.*commit' CLAUDE.md 2>/dev/null; then RULE_SUGGESTIONS+=("worktree-guard - your CLAUDE.md mentions worktree safety") fi fi if [ ${#RULE_SUGGESTIONS[@]} -gt 0 ]; then echo "" printf "${YELLOW}${BOLD}Rules in CLAUDE.md that could be enforced:${NC}\n" for suggestion in "${RULE_SUGGESTIONS[@]}"; do printf " ${YELLOW}→${NC} %s\n" "$suggestion" done printf "${DIM} These are advisory until backed by hooks. Install the hooks above or use enforce-hooks.${NC}\n" fi fi # === Section 8a: Hook Inventory === # Show all registered hooks, both from Boucle-framework and custom/third-party KNOWN_HOOKS="bash-guard git-safe file-guard branch-guard worktree-guard session-log read-once enforce-hooks enforce" CUSTOM_HOOKS=() TOTAL_HOOKS=0 BOUCLE_HOOKS=0 if [ -n "$ALL_HOOK_CMDS" ]; then while IFS= read -r cmd_entry; do [ -z "$cmd_entry" ] && continue TOTAL_HOOKS=$((TOTAL_HOOKS + 1)) # Check if this is a known Boucle-framework hook is_known=false for known in $KNOWN_HOOKS; do if echo "$cmd_entry" | grep -q "$known"; then is_known=true BOUCLE_HOOKS=$((BOUCLE_HOOKS + 1)) break fi done if [ "$is_known" = "false" ]; then # Extract just the command part (after hook_type:source:) cmd_part="${cmd_entry#*:*:}" hook_basename=$(basename "$cmd_part" | head -c 50) hook_type="${cmd_entry%%:*}" CUSTOM_HOOKS+=("$hook_type: $hook_basename") fi done <<< "$ALL_HOOK_CMDS" if [ ${#CUSTOM_HOOKS[@]} -gt 0 ]; then echo "" printf "${BLUE}Hook Inventory${NC}\n" printf " %d hook(s) registered" "$TOTAL_HOOKS" if [ "$BOUCLE_HOOKS" -gt 0 ]; then printf " (%d Boucle-framework" "$BOUCLE_HOOKS" printf ", %d custom/third-party)\n" "${#CUSTOM_HOOKS[@]}" else printf " (all custom/third-party)\n" fi for custom in "${CUSTOM_HOOKS[@]}"; do printf " ${DIM}%s${NC}\n" "$custom" done fi fi # === Section 8b: Hook Health === # Verify that registered hooks actually exist and are executable HOOK_HEALTH_ISSUES=0 HOOK_PATHS="" VERIFY_RAN=0 VERIFY_NO_HOOKS=0 VERIFY_PASS=0 VERIFY_FAIL=0 VERIFY_SKIP=0 VERIFY_PRETOOLUSE_SKIP=0 VERIFY_TOTAL=0 HOOK_VERIFY_TIMEOUT_SECONDS="${HOOK_VERIFY_TIMEOUT_SECONDS:-5}" _hook_script_path() { python3 - "$1" << 'PYEOF_HOOKPATH' import os import re import shlex import sys command = sys.argv[1] try: parts = shlex.split(command) except ValueError: sys.exit(0) if not parts: sys.exit(0) first = os.path.basename(parts[0]).lower() path = "" if first in {"bash", "sh", "zsh", "python", "python3"}: for part in parts[1:]: if part == "--": continue if part == "-c": path = "" break if part.startswith("-"): continue path = part break elif first in {"pwsh", "powershell", "powershell.exe"}: for i, part in enumerate(parts[1:], start=1): if part.lower() == "-file" and i + 1 < len(parts): path = parts[i + 1] break else: path = parts[0] if not path: sys.exit(0) def expand_shell_vars(value): """Expand common shell path vars without executing shell syntax.""" pattern = re.compile( r"\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)" ) def repl(match): braced_name = match.group(1) default = match.group(3) simple_name = match.group(4) name = braced_name or simple_name current = os.environ.get(name, "") if braced_name and default is not None and not current: return expand_shell_vars(default) if name not in os.environ: return match.group(0) return current previous = None current = value for _ in range(10): if current == previous: break previous = current current = pattern.sub(repl, current) return current path = os.path.expanduser(expand_shell_vars(path)) if "/" in path or "\\" in path or path.endswith((".sh", ".py", ".ps1")): print(path) PYEOF_HOOKPATH } _hook_interpreter_name() { python3 - "$1" << 'PYEOF_HOOKINTERP' import os import shlex import sys try: parts = shlex.split(sys.argv[1]) except ValueError: sys.exit(0) if not parts: sys.exit(0) first = os.path.basename(parts[0]).lower() if first in {"bash", "sh", "zsh", "python", "python3", "pwsh", "powershell", "powershell.exe"}: print(first) PYEOF_HOOKINTERP } _hook_display_path() { local path="$1" local display="$path" if [ -n "${HOME:-}" ] && [ "$HOME" != "/" ]; then display="${display//"$HOME"/~}" fi if [ -n "${PWD:-}" ] && [ "$PWD" != "/" ]; then display="${display//"$PWD"/}" fi printf "%s" "$display" | cut -c1-120 } _run_hook_with_timeout() { local interpreter="$1" local script_path="$2" local timeout_seconds="$3" local payload="$4" python3 - "$interpreter" "$script_path" "$timeout_seconds" "$payload" << 'PYEOF_RUNHOOK' import os import signal import subprocess import sys interpreter = sys.argv[1] script_path = sys.argv[2] try: timeout_seconds = float(sys.argv[3]) except Exception: timeout_seconds = 5.0 payload = sys.argv[4] if interpreter in {"bash", "sh", "zsh", "python", "python3"}: cmd = [interpreter, script_path] elif interpreter in {"pwsh", "powershell", "powershell.exe"}: cmd = [interpreter, "-File", script_path] else: cmd = [script_path] proc = None try: proc = subprocess.Popen( cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE, start_new_session=True, ) stdout, stderr = proc.communicate(input=payload, timeout=timeout_seconds) except subprocess.TimeoutExpired as exc: try: if proc is not None: os.killpg(proc.pid, signal.SIGKILL) os.kill(proc.pid, signal.SIGKILL) except Exception: pass try: if proc is not None and proc.poll() is None: subprocess.run(["/bin/kill", "-KILL", f"-{proc.pid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) except Exception: pass try: stdout, stderr = proc.communicate(timeout=1) if proc is not None else ("", "") except Exception: stdout, stderr = exc.stdout or "", exc.stderr or "" if isinstance(stdout, bytes): stdout = stdout.decode(errors="replace") if isinstance(stderr, bytes): stderr = stderr.decode(errors="replace") sys.stdout.write(stdout) sys.stderr.write(stderr) sys.stderr.write(f"hook timed out after {timeout_seconds:g} seconds\n") sys.exit(124) sys.stdout.write(stdout or "") sys.stderr.write(stderr or "") sys.exit(proc.returncode if proc is not None else 1) PYEOF_RUNHOOK } # Collect hook commands from both user and project settings. # By default this includes every hook event for health checks. Pass event names # to narrow the result for payload verification. _extract_hook_paths() { local file="$1" shift || true [ -f "$file" ] || return 0 python3 - "$file" "$@" << 'PYEOF' import json, sys try: with open(sys.argv[1]) as f: s = json.load(f) requested = sys.argv[2:] all_hook_types = requested or [ "PreToolUse", "PostToolUse", "SessionStart", "SessionEnd", "PostCompact", "Stop", "SubagentStop", "TaskCreated", "WorktreeCreate", "WorktreeRemove", "UserPromptSubmit", "Notification", "PermissionDenied", ] for hook_type in all_hook_types: for entry in s.get("hooks", {}).get(hook_type, []): for hook in entry.get("hooks", []): cmd = hook.get("command", "") if cmd: print(cmd) cmd = entry.get("command", "") if cmd: print(cmd) except Exception: pass PYEOF } _merge_hook_paths() { local current="$1" local extra="$2" if [ -n "$extra" ]; then if [ -n "$current" ]; then printf "%s\n%s\n" "$current" "$extra" else printf "%s\n" "$extra" fi else printf "%s\n" "$current" fi } _sort_hook_paths() { local paths="$1" if [ -n "$paths" ]; then printf "%s\n" "$paths" | sort -u fi } _count_nonempty_lines() { local lines="$1" if [ -z "$lines" ]; then printf "0" else printf "%s\n" "$lines" | awk 'NF { count++ } END { print count + 0 }' fi } HOOK_PATHS=$(_extract_hook_paths "$SETTINGS_FILE" 2>/dev/null) VERIFY_HOOK_PATHS=$(_extract_hook_paths "$SETTINGS_FILE" PreToolUse 2>/dev/null) if [ -f "$PROJECT_SETTINGS" ]; then PROJECT_HOOK_PATHS=$(_extract_hook_paths "$PROJECT_SETTINGS" 2>/dev/null) HOOK_PATHS=$(_merge_hook_paths "$HOOK_PATHS" "$PROJECT_HOOK_PATHS") PROJECT_VERIFY_HOOK_PATHS=$(_extract_hook_paths "$PROJECT_SETTINGS" PreToolUse 2>/dev/null) VERIFY_HOOK_PATHS=$(_merge_hook_paths "$VERIFY_HOOK_PATHS" "$PROJECT_VERIFY_HOOK_PATHS") fi # Deduplicate hook paths (same hook in user+project = show once) HOOK_PATHS=$(_sort_hook_paths "$HOOK_PATHS") VERIFY_HOOK_PATHS=$(_sort_hook_paths "$VERIFY_HOOK_PATHS") NON_PRETOOLUSE_HOOK_PATHS="" if [ -n "$HOOK_PATHS" ]; then if [ -n "$VERIFY_HOOK_PATHS" ]; then NON_PRETOOLUSE_HOOK_PATHS=$(comm -23 <(printf "%s\n" "$HOOK_PATHS") <(printf "%s\n" "$VERIFY_HOOK_PATHS") || true) else NON_PRETOOLUSE_HOOK_PATHS="$HOOK_PATHS" fi fi if [ -n "$HOOK_PATHS" ]; then echo "" printf "${BLUE}Hook Health${NC}\n" while IFS= read -r hook_path; do [ -z "$hook_path" ] && continue expanded_path=$(_hook_script_path "$hook_path" 2>/dev/null || true) if [ -z "$expanded_path" ]; then hook_basename=$(printf "%s" "$hook_path" | cut -c1-60) printf " ${DIM}- %s - custom command, not file-checked${NC}\n" "$hook_basename" continue fi hook_label=$(_hook_display_path "$expanded_path") if [ ! -f "$expanded_path" ]; then printf " ${RED}✗${NC} %s - file not found\n" "$hook_label" HOOK_HEALTH_ISSUES=$((HOOK_HEALTH_ISSUES + 1)) elif [ ! -x "$expanded_path" ] && [ -z "$(_hook_interpreter_name "$hook_path" 2>/dev/null || true)" ]; then printf " ${RED}✗${NC} %s - not executable (run chmod +x on this file)\n" "$hook_label" HOOK_HEALTH_ISSUES=$((HOOK_HEALTH_ISSUES + 1)) else printf " ${GREEN}✓${NC} %s\n" "$hook_label" fi done <<< "$HOOK_PATHS" if [ "$HOOK_HEALTH_ISSUES" -gt 0 ]; then _HOOK_HEALTH_ISSUE="$HOOK_HEALTH_ISSUES hook(s) are broken (missing or not executable). Hooks that don't exist fail silently." ISSUES+=("$_HOOK_HEALTH_ISSUE") summary_issue "$_HOOK_HEALTH_ISSUE" fi fi # === Section 9: Verify Mode - test hooks with representative payloads === if [ "$VERIFY_MODE" = "1" ] && [ -n "$HOOK_PATHS" ]; then echo "" printf "${BOLD}Hook Verification${NC} ${DIM}(sending representative test payloads)${NC}\n" VERIFY_RAN=1 VERIFY_PASS=0 VERIFY_FAIL=0 VERIFY_SKIP=0 VERIFY_PRETOOLUSE_SKIP=0 VERIFY_TOTAL=0 VERIFY_TIMEOUT=0 # Test payloads for known hooks BASH_GUARD_PAYLOAD='{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' GIT_SAFE_PAYLOAD='{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' # Use absolute path so file-guard tests config pattern matching, not the relative-path rejection FILE_GUARD_PAYLOAD="{\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$(pwd)/.env\",\"content\":\"SECRET=exposed\"}}" FILE_GUARD_MULTIEDIT_PAYLOAD="{\"tool_name\":\"MultiEdit\",\"tool_input\":{\"file_path\":\"$(pwd)/.env\",\"edits\":[{\"old_string\":\"SECRET=old\",\"new_string\":\"SECRET=exposed\"}]}}" FILE_GUARD_NOTEBOOK_PAYLOAD="{\"tool_name\":\"NotebookEdit\",\"tool_input\":{\"notebook_path\":\"$(pwd)/.env\",\"new_source\":\"SECRET=exposed\"}}" BRANCH_GUARD_PAYLOAD='{"tool_name":"Bash","tool_input":{"command":"git commit -m test"}}' NONMATCH_PAYLOAD='{"tool_name":"Read","tool_input":{"file_path":"README.md"}}' verify_hook() { local name="$1" local hook_cmd="$2" local payload="$3" local expect_block="$4" # true = should block, false = should pass, skip = skip if [ "$expect_block" = "skip" ]; then VERIFY_SKIP=$((VERIFY_SKIP + 1)) VERIFY_PRETOOLUSE_SKIP=$((VERIFY_PRETOOLUSE_SKIP + 1)) printf " ${DIM}- %s (skipped, needs runtime state)${NC}\n" "$name" return fi # Extract the script path from commands like "bash ~/.claude/hooks/foo.sh" local script_path="" script_path=$(_hook_script_path "$hook_cmd" 2>/dev/null || true) if [ -z "$script_path" ]; then VERIFY_SKIP=$((VERIFY_SKIP + 1)) VERIFY_PRETOOLUSE_SKIP=$((VERIFY_PRETOOLUSE_SKIP + 1)) printf " ${DIM}- %s (skipped, custom command is not a direct hook script)${NC}\n" "$name" return fi VERIFY_TOTAL=$((VERIFY_TOTAL + 1)) if [ ! -f "$script_path" ]; then VERIFY_FAIL=$((VERIFY_FAIL + 1)) printf " ${RED}✗${NC} %s - script not found: %s\n" "$name" "$script_path" return fi local interpreter="" interpreter=$(_hook_interpreter_name "$hook_cmd" 2>/dev/null || true) if [ ! -x "$script_path" ] && [ -z "$interpreter" ]; then VERIFY_FAIL=$((VERIFY_FAIL + 1)) printf " ${RED}✗${NC} %s - not executable\n" "$name" return fi local output="" local stderr_output="" local exit_code=0 local stdout_file="" local stderr_file="" # Run the hook (timeout via background+wait if coreutils timeout unavailable) stdout_file=$(mktemp) stderr_file=$(mktemp) if _run_hook_with_timeout "$interpreter" "$script_path" "$HOOK_VERIFY_TIMEOUT_SECONDS" "$payload" >"$stdout_file" 2>"$stderr_file"; then exit_code=0 else exit_code=$? fi output=$(cat "$stdout_file") stderr_output=$(cat "$stderr_file") rm -f "$stdout_file" "$stderr_file" if [ "$exit_code" -eq 124 ]; then VERIFY_TIMEOUT=$((VERIFY_TIMEOUT + 1)) fi if [ "$expect_block" = "true" ]; then # Should have blocked. Strict verification requires a JSON block/deny # decision on stdout; non-strict verification still accepts the # documented exit-code-2 path but the audit warns about its platform risk. if { [ "$exit_code" -eq 0 ] && [ -n "$output" ] && echo "$output" | grep -qE '"permissionDecision"[[:space:]]*:[[:space:]]*"deny"|"decision"[[:space:]]*:[[:space:]]*"block"'; } || \ { [ "$STRICT_MODE" != "1" ] && [ "$exit_code" -eq 2 ] && [ -z "$output" ] && [ -n "$stderr_output" ]; }; then VERIFY_PASS=$((VERIFY_PASS + 1)) printf " ${GREEN}✓${NC} %s - blocks correctly\n" "$name" else VERIFY_FAIL=$((VERIFY_FAIL + 1)) printf " ${RED}✗${NC} %s - did NOT block ${RED}(FAIL-OPEN)${NC}\n" "$name" if [ -n "$output" ]; then printf " ${DIM}Output: %s${NC}\n" "$(echo "$output" | head -1 | cut -c1-80)" elif [ -n "$stderr_output" ]; then printf " ${DIM}Stderr: %s${NC}\n" "$(echo "$stderr_output" | head -1 | cut -c1-80)" else printf " ${DIM}No output (silent fail-open)${NC}\n" fi fi else # Should pass through - just verify it doesn't crash if [ "$exit_code" -eq 0 ]; then VERIFY_PASS=$((VERIFY_PASS + 1)) printf " ${GREEN}✓${NC} %s - passes safe payload\n" "$name" else VERIFY_FAIL=$((VERIFY_FAIL + 1)) printf " ${RED}✗${NC} %s - crashed on safe payload (exit %d)\n" "$name" "$exit_code" fi fi } if [ -n "$NON_PRETOOLUSE_HOOK_PATHS" ]; then while IFS= read -r hook_cmd; do [ -z "$hook_cmd" ] && continue VERIFY_SKIP=$((VERIFY_SKIP + 1)) hook_basename=$(printf "%s" "$hook_cmd" | cut -c1-60) printf " ${DIM}- %s (skipped, not a PreToolUse hook)${NC}\n" "$hook_basename" done <<< "$NON_PRETOOLUSE_HOOK_PATHS" fi # For each hook command, identify what it is and test it while IFS= read -r hook_cmd; do [ -z "$hook_cmd" ] && continue if echo "$hook_cmd" | grep -q "bash-guard"; then verify_hook "bash-guard blocks rm -rf /" "$hook_cmd" "$BASH_GUARD_PAYLOAD" "true" verify_hook "bash-guard passes safe commands" "$hook_cmd" "$NONMATCH_PAYLOAD" "false" elif echo "$hook_cmd" | grep -q "git-safe"; then verify_hook "git-safe blocks force push" "$hook_cmd" "$GIT_SAFE_PAYLOAD" "true" verify_hook "git-safe passes safe commands" "$hook_cmd" "$NONMATCH_PAYLOAD" "false" elif echo "$hook_cmd" | grep -q "file-guard"; then # file-guard requires a .file-guard config to know what to protect if [ -f ".file-guard" ] || [ -n "${FILE_GUARD_CONFIG:-}" ]; then verify_hook "file-guard blocks .env write" "$hook_cmd" "$FILE_GUARD_PAYLOAD" "true" verify_hook "file-guard blocks .env MultiEdit" "$hook_cmd" "$FILE_GUARD_MULTIEDIT_PAYLOAD" "true" verify_hook "file-guard blocks .env NotebookEdit" "$hook_cmd" "$FILE_GUARD_NOTEBOOK_PAYLOAD" "true" verify_hook "file-guard passes safe reads" "$hook_cmd" "$NONMATCH_PAYLOAD" "false" else VERIFY_SKIP=$((VERIFY_SKIP + 1)) VERIFY_PRETOOLUSE_SKIP=$((VERIFY_PRETOOLUSE_SKIP + 1)) printf " ${DIM}- file-guard (skipped, no .file-guard config found)${NC}\n" printf " ${DIM}Create .file-guard with paths to protect, e.g.: echo '.env' > .file-guard${NC}\n" fi elif echo "$hook_cmd" | grep -q "branch-guard"; then verify_hook "branch-guard (git state dependent)" "$hook_cmd" "$BRANCH_GUARD_PAYLOAD" "skip" elif echo "$hook_cmd" | grep -q "worktree-guard"; then verify_hook "worktree-guard (git state dependent)" "$hook_cmd" "$NONMATCH_PAYLOAD" "skip" elif echo "$hook_cmd" | grep -q "session-log"; then verify_hook "session-log accepts payloads" "$hook_cmd" "$NONMATCH_PAYLOAD" "false" elif echo "$hook_cmd" | grep -q "read-once"; then verify_hook "read-once (session state dependent)" "$hook_cmd" "$NONMATCH_PAYLOAD" "skip" elif echo "$hook_cmd" | grep -q "enforce"; then verify_hook "enforce-hooks accepts payloads" "$hook_cmd" "$NONMATCH_PAYLOAD" "false" else # Unknown hook - just test it doesn't crash hook_basename=$(basename "$hook_cmd" | head -1) verify_hook "$hook_basename accepts payloads" "$hook_cmd" "$NONMATCH_PAYLOAD" "false" fi done <<< "$VERIFY_HOOK_PATHS" # Summary echo "" if [ "$VERIFY_FAIL" -gt 0 ]; then printf " ${RED}%d/%d payload checks FAIL-OPEN${NC}" "$VERIFY_FAIL" "$VERIFY_TOTAL" if [ "$VERIFY_SKIP" -gt 0 ]; then printf " ${DIM}(%d skipped)${NC}" "$VERIFY_SKIP" fi echo "" if [ "$VERIFY_TIMEOUT" -gt 0 ]; then _TIMEOUT_ISSUE="$VERIFY_TIMEOUT hook payload check(s) timed out after ${HOOK_VERIFY_TIMEOUT_SECONDS} seconds. Timed-out hooks did not prove enforcement." ISSUES+=("$_TIMEOUT_ISSUE") summary_issue "$_TIMEOUT_ISSUE" fi ISSUES+=("$VERIFY_FAIL hook payload check(s) did not block when they should have. This means dangerous commands can execute unchecked.") elif [ "$VERIFY_TOTAL" -eq 0 ] && [ "$VERIFY_SKIP" -gt 0 ]; then printf " ${YELLOW}No payload checks ran${NC} ${DIM}(%d skipped)${NC}\n" "$VERIFY_SKIP" else printf " ${GREEN}All %d payload checks passed${NC}" "$VERIFY_PASS" if [ "$VERIFY_SKIP" -gt 0 ]; then printf " ${DIM}(%d skipped)${NC}" "$VERIFY_SKIP" fi echo "" fi elif [ "$VERIFY_MODE" = "1" ]; then VERIFY_RAN=1 VERIFY_NO_HOOKS=1 echo "" printf "${DIM}No hooks found to verify. Install hooks first.${NC}\n" fi # === Results === echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━" # Calculate percentage if [ "$MAX_SCORE" -gt 0 ]; then PCT=$((SCORE * 100 / MAX_SCORE)) else PCT=0 fi # Grade if [ "$PCT" -ge 90 ]; then GRADE="A" GRADE_COLOR="$GREEN" VERDICT="Excellent. Your Claude Code setup is well-protected." elif [ "$PCT" -ge 70 ]; then GRADE="B" GRADE_COLOR="$GREEN" VERDICT="Good. A few gaps worth addressing." elif [ "$PCT" -ge 50 ]; then GRADE="C" GRADE_COLOR="$YELLOW" VERDICT="Fair. Several important protections are missing." elif [ "$PCT" -ge 30 ]; then GRADE="D" GRADE_COLOR="$RED" VERDICT="Poor. Claude has too much unguarded access." else GRADE="F" GRADE_COLOR="$RED" VERDICT="Unsafe. Claude can do almost anything unchecked." fi printf "\n${BOLD}Safety Score: ${GRADE_COLOR}%d/%d (%d%%) - Grade %s${NC}\n" "$SCORE" "$MAX_SCORE" "$PCT" "$GRADE" printf "%s\n" "$VERDICT" printf "${DIM}%d/%d checks passed${NC}\n" "$CHECKS_PASSED" "$CHECKS_TOTAL" if [ "$VERIFY_RAN" = "1" ]; then echo "" printf "${BOLD}Verification boundary:${NC}\n" if [ "$VERIFY_NO_HOOKS" = "1" ]; then printf " ${YELLOW}No hooks were found to verify.${NC} Install hooks before trusting the hook layer.\n" elif [ "$VERIFY_TOTAL" -eq 0 ] && [ "$VERIFY_PRETOOLUSE_SKIP" -gt 0 ]; then printf " ${YELLOW}No hook payload checks ran.${NC} Resolve skipped PreToolUse hooks before trusting the hook layer.\n" elif [ "$VERIFY_TOTAL" -eq 0 ]; then printf " ${YELLOW}No PreToolUse payload checks ran.${NC} Add or verify PreToolUse hooks before trusting the hook layer.\n" elif [ "$VERIFY_FAIL" -gt 0 ]; then printf " ${RED}%d hook(s) still FAIL-OPEN.${NC} Fix those before trusting the hook layer.\n" "$VERIFY_FAIL" elif [ "$VERIFY_PRETOOLUSE_SKIP" -gt 0 ]; then printf " ${YELLOW}%d PreToolUse hook check(s) were skipped.${NC} Resolve them before using strict gating.\n" "$VERIFY_PRETOOLUSE_SKIP" else printf " ${GREEN}Zero FAIL-OPEN hooks in representative checks.${NC}\n" printf " If bypass flags, invalid settings JSON, and hook-health issues are also clear,\n" printf " treat remaining Claude Code warnings as residual platform risk, not a reason to reinstall hooks.\n" fi fi # Show issues and fixes if [ ${#ISSUES[@]} -gt 0 ]; then echo "" printf "${BOLD}Issues:${NC}\n" for issue in "${ISSUES[@]}"; do printf " ${YELLOW}⚠${NC} %s\n" "$issue" done fi if [ ${#FIXES[@]} -gt 0 ]; then echo "" printf "${BOLD}Quick fixes:${NC}\n" for fix in "${FIXES[@]}"; do if [ -n "$fix" ]; then printf " ${DIM}\$${NC} %s\n" "$fix" fi done fi # Install all suggestion MISSING=$((CHECKS_TOTAL - CHECKS_PASSED)) if [ "$MISSING" -gt 2 ]; then echo "" printf "${BOLD}Or install all hooks at once:${NC}\n" printf " ${DIM}\$${NC} curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/install.sh | bash -s -- all\n" fi # Compact plain-text summary (no colors, easy to copy/share) _hook_mark() { has_hook "$1" && printf "+" || printf "-"; } _installed_count=0 for _h in bash-guard git-safe file-guard read-once branch-guard session-log enforce-hooks worktree-guard; do has_hook "$_h" && _installed_count=$((_installed_count + 1)) done if [ "$SUMMARY_ONLY" = "1" ]; then exec 1>&3 exec 3>&- rm -f "$SUMMARY_ONLY_TMP" SUMMARY_ONLY_TMP="" fi echo "--- Safety Summary (copy/paste) ---" printf "Grade %s | %d%% | %d/8 hooks\n" "$GRADE" "$PCT" "$_installed_count" printf "[%s] bash-guard [%s] git-safe [%s] file-guard [%s] read-once\n" \ "$(_hook_mark bash-guard)" "$(_hook_mark git-safe)" "$(_hook_mark file-guard)" "$(_hook_mark read-once)" printf "[%s] branch-guard [%s] session-log [%s] enforce [%s] worktree-guard\n" \ "$(_hook_mark branch-guard)" "$(_hook_mark session-log)" "$(_hook_mark enforce-hooks)" "$(_hook_mark worktree-guard)" if [ ${#SUMMARY_ISSUES[@]} -gt 0 ]; then for _summary_issue in "${SUMMARY_ISSUES[@]}"; do printf "Issue: %s\n" "$_summary_issue" done fi if [ "$VERIFY_RAN" = "1" ]; then if [ "$VERIFY_NO_HOOKS" = "1" ]; then printf "Verify: not run | no hooks found | 0 payload checks\n" printf "Boundary: install hooks before trusting the hook layer.\n" else printf "Verify: %d FAIL-OPEN | %d payload checks | %d skipped\n" "$VERIFY_FAIL" "$VERIFY_TOTAL" "$VERIFY_SKIP" fi if [ "$VERIFY_NO_HOOKS" != "1" ] && [ "$VERIFY_FAIL" -gt 0 ]; then printf "Boundary: fix FAIL-OPEN hooks before trusting the hook layer.\n" elif [ "$VERIFY_NO_HOOKS" != "1" ] && [ "$VERIFY_TOTAL" -eq 0 ] && [ "$VERIFY_PRETOOLUSE_SKIP" -gt 0 ]; then printf "Boundary: no hook payload checks ran; resolve skipped PreToolUse hooks before trusting the hook layer.\n" elif [ "$VERIFY_NO_HOOKS" != "1" ] && [ "$VERIFY_TOTAL" -eq 0 ]; then printf "Boundary: no PreToolUse payload checks ran; add or verify PreToolUse hooks before trusting the hook layer.\n" elif [ "$VERIFY_NO_HOOKS" != "1" ] && [ "$VERIFY_PRETOOLUSE_SKIP" -gt 0 ]; then printf "Boundary: resolve skipped PreToolUse hook checks before trusting strict verification.\n" elif [ "$VERIFY_NO_HOOKS" != "1" ]; then printf "Boundary: hooks passed representative checks; document residual platform warnings.\n" fi fi printf "github.com/Bande-a-Bonnot/Boucle-framework\n" printf "%s\n" "--- End Safety Summary ---" if [ "$SUMMARY_ONLY" != "1" ]; then echo "" printf "${DIM}https://github.com/Bande-a-Bonnot/Boucle-framework/tree/main/tools${NC}\n" echo "" fi if [ "$STRICT_MODE" = "1" ]; then if [ "$VERIFY_NO_HOOKS" = "1" ] || [ "$VERIFY_TOTAL" -eq 0 ] || [ "$VERIFY_FAIL" -gt 0 ] || [ "$VERIFY_PRETOOLUSE_SKIP" -gt 0 ] || [ "$HOOK_HEALTH_ISSUES" -gt 0 ]; then exit 1 fi fi