config-change-guard.sh 3.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. #!/bin/bash
  2. # hooks/config-change-guard.sh
  3. # ConfigChange hook — single-file worm-persistence scan when a Claude settings
  4. # file changes mid-session.
  5. #
  6. # The 2026 worm family (Shai-Hulud / Mini Shai-Hulud) persists by injecting
  7. # hooks / mcpServers entries into Claude Code and editor settings. The full
  8. # sweep is skills/supply-chain-defense/scripts/integrity-audit.sh; this hook is
  9. # the fast inline tripwire: when the harness reports a settings change, scan
  10. # JUST that file for the same vetted IOC patterns (curl|sh, base64 -d eval,
  11. # Invoke-Expression+Download, /dev/tcp, reads of .claude/settings or
  12. # .aws/credentials).
  13. #
  14. # Event contract (verified against https://code.claude.com/docs/en/hooks):
  15. # ConfigChange stdin payload carries common fields (cwd, hook_event_name, …)
  16. # plus `source`: user_settings | project_settings | local_settings |
  17. # policy_settings | skills. There is NO file_path field — we map source to
  18. # the file ourselves. We still read .file_path if a future harness adds it,
  19. # and accept a file path as $1 for manual/offline testing.
  20. #
  21. # NOTE: ConfigChange does NOT fire for VS Code settings.json or ~/.claude.json
  22. # — those persistence surfaces are covered by the periodic integrity-audit.sh
  23. # sweep, not by this event. policy_settings can't be blocked (harness rule)
  24. # and `skills` has no single file → both are silently skipped.
  25. #
  26. # Behaviour (silent guardian — rules/prompt-injection.md noise discipline):
  27. # clean → no output, exit 0
  28. # IOC found → ADVISORY: systemMessage JSON on stdout, exit 0
  29. # + SUPPLY_CHAIN_BLOCK=1 → HARD GATE: stderr + exit 2 (change blocked;
  30. # ConfigChange is blockable for non-policy sources)
  31. #
  32. # Exit codes: 0 allow (clean or advisory), 2 block (IOC + SUPPLY_CHAIN_BLOCK=1)
  33. set -uo pipefail # NOT -e: a guard must not crash into a false block
  34. HAS_JQ=0; command -v jq >/dev/null 2>&1 && HAS_JQ=1
  35. # ── Resolve the changed file: stdin JSON → source map → $1 fallback ─────────
  36. FILE=""; SRC=""; CWD=""
  37. if [[ ! -t 0 ]]; then
  38. RAW="$(cat 2>/dev/null)"
  39. if [[ -n "${RAW:-}" && "$HAS_JQ" -eq 1 ]]; then
  40. FILE="$(printf '%s' "$RAW" | jq -r '.file_path // empty' 2>/dev/null)"
  41. SRC="$(printf '%s' "$RAW" | jq -r '.source // empty' 2>/dev/null)"
  42. CWD="$(printf '%s' "$RAW" | jq -r '.cwd // empty' 2>/dev/null)"
  43. fi
  44. fi
  45. [[ -z "$CWD" ]] && CWD="${CLAUDE_PROJECT_DIR:-$PWD}"
  46. if [[ -z "$FILE" ]]; then
  47. case "$SRC" in
  48. user_settings) FILE="$HOME/.claude/settings.json" ;;
  49. project_settings) FILE="$CWD/.claude/settings.json" ;;
  50. local_settings) FILE="$CWD/.claude/settings.local.json" ;;
  51. policy_settings|skills) exit 0 ;; # unblockable / no single file
  52. *) FILE="${1:-}" ;; # offline-test fallback: path as $1
  53. esac
  54. fi
  55. [[ -z "$FILE" || ! -f "$FILE" ]] && exit 0
  56. # ── IOC patterns — reused verbatim from integrity-audit.sh SHELL_SUSPECT ───
  57. # (curl|sh, wget|sh, base64 decode, eval-of-subshell, settings/cred reads,
  58. # reverse shell, PowerShell download-exec). Vetted there; do not fork them.
  59. SUSPECT='curl[^|]*\|[[:space:]]*(ba)?sh|wget[^|]*\|[[:space:]]*(ba)?sh|base64[[:space:]]+--?d|eval[[:space:]]+"?\$\(|\.claude[/\\]\.?settings|\.aws[/\\]credentials|/dev/tcp/|[Ii]nvoke-Expression[^;]*[Dd]ownload'
  60. HITS="$(grep -nEi "$SUSPECT" "$FILE" 2>/dev/null)"
  61. [[ -z "$HITS" ]] && exit 0 # clean → silent
  62. FLAT="$(printf '%s' "$HITS" | head -5 | tr '\n' ';' )"
  63. MSG="CONFIG GUARD: worm-persistence IOC in changed settings file ($FILE): ${FLAT} — confirm YOU added this. If unexplained, treat as an incident: run skills/supply-chain-defense/scripts/integrity-audit.sh, isolate, rotate credentials."
  64. if [[ "${SUPPLY_CHAIN_BLOCK:-0}" == "1" ]]; then
  65. echo "$MSG" >&2
  66. echo "Blocked (SUPPLY_CHAIN_BLOCK=1). Review the change before allowing it." >&2
  67. exit 2
  68. fi
  69. if [[ "$HAS_JQ" -eq 1 ]]; then
  70. jq -n --arg m "$MSG" '{systemMessage: $m}'
  71. else
  72. echo "$MSG"
  73. fi
  74. exit 0