cc-safe-setup 29.6.27 → 29.6.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/COOKBOOK.md CHANGED
@@ -215,9 +215,32 @@ npx cc-safe-setup --install-example classifier-fallback-allow
215
215
 
216
216
  PermissionRequest hook that approves cat, ls, grep, git read-only when the classifier can't respond.
217
217
 
218
+ ## Block Reading Credential Files
219
+
220
+ Prevent the agent from displaying tokens in conversations by reading package manager credential files:
221
+
222
+ ```bash
223
+ npx cc-safe-setup --install-example credential-file-cat-guard
224
+ ```
225
+
226
+ Blocks `cat`, `head`, `tail`, `grep` on `~/.netrc`, `~/.npmrc`, `~/.cargo/credentials`, `~/.docker/config.json`, `~/.kube/config`, and more. Complements `credential-exfil-guard` which blocks hunting patterns. See [#34819](https://github.com/anthropics/claude-code/issues/34819).
227
+
228
+ ## Require Tests Before Push
229
+
230
+ Block `git push` to protected branches unless tests have passed in the current session:
231
+
232
+ ```bash
233
+ npx cc-safe-setup --install-example push-requires-test-pass
234
+ npx cc-safe-setup --install-example push-requires-test-pass-record
235
+ ```
236
+
237
+ Two-hook system: the PostToolUse `record` hook detects successful test runs (`npm test`, `pytest`, `cargo test`, etc.) and saves a timestamp. The PreToolUse hook blocks push to main/master/production if no recent test pass exists (30-minute window). See [#36673](https://github.com/anthropics/claude-code/issues/36673).
238
+
218
239
  ## Further Reading
219
240
 
220
241
  - [Getting Started](https://yurukusa.github.io/cc-safe-setup/getting-started.html)
221
242
  - [Common Mistakes](https://yurukusa.github.io/cc-safe-setup/common-mistakes.html)
243
+ - [Auto-Approve Guide](https://yurukusa.github.io/cc-safe-setup/auto-approve-guide.html)
244
+ - [Credential Protection](https://yurukusa.github.io/cc-safe-setup/prevent-credential-leak.html)
222
245
  - [Troubleshooting](TROUBLESHOOTING.md)
223
246
  - [Settings Reference](SETTINGS_REFERENCE.md)
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dw/cc-safe-setup)](https://www.npmjs.com/package/cc-safe-setup)
5
5
  [![tests](https://github.com/yurukusa/cc-safe-setup/actions/workflows/test.yml/badge.svg)](https://github.com/yurukusa/cc-safe-setup/actions/workflows/test.yml)
6
6
 
7
- **One command to make Claude Code safe for autonomous operation.** 507 example hooks · 7,341 tests · 1,000+ installs/day · [日本語](docs/README.ja.md)
7
+ **One command to make Claude Code safe for autonomous operation.** 514 example hooks · 7,564 tests · 1,000+ installs/day · [日本語](docs/README.ja.md)
8
8
 
9
9
  ```bash
10
10
  npx cc-safe-setup
@@ -117,7 +117,7 @@ Install any of these: `npx cc-safe-setup --install-example <name>`
117
117
  | `--scan [--apply]` | Tech stack detection |
118
118
  | `--export / --import` | Team config sharing |
119
119
  | `--verify` | Test each hook |
120
- | `--install-example <name>` | Install from 507 examples |
120
+ | `--install-example <name>` | Install from 514 examples |
121
121
  | `--examples [filter]` | Browse examples by keyword |
122
122
  | `--full` | All-in-one setup |
123
123
  | `--status` | Check installed hooks |
@@ -1,26 +1,44 @@
1
1
  # Example Hooks
2
2
 
3
- 38 installable hooks. Each solves a real problem from GitHub Issues or autonomous operation.
3
+ 514 installable hooks. Each solves a real problem from GitHub Issues or autonomous operation. 7,564 tests.
4
4
 
5
5
  ```bash
6
- npx cc-safe-setup --install-example <name>
7
- npx cc-safe-setup --examples # list all
6
+ npx cc-safe-setup --install-example <name> # install one
7
+ npx cc-safe-setup --examples # list all
8
+ npx cc-safe-setup --examples safety # filter by category
9
+ npx cc-safe-setup --shield # install recommended set
8
10
  ```
9
11
 
10
- ## Safety Guards (13)
11
- allowlist, block-database-wipe, case-sensitive-guard, compound-command-approver, deploy-guard, env-var-check, git-config-guard, network-guard, path-traversal-guard, protect-dotfiles, scope-guard, test-before-push, timeout-guard
12
+ ## Categories
12
13
 
13
- ## Auto-Approve (5)
14
- auto-approve-build, auto-approve-docker, auto-approve-git-read, auto-approve-python, auto-approve-ssh
14
+ | Category | Count | Examples |
15
+ |----------|-------|---------|
16
+ | Destructive Command Prevention | 12 | `destructive-guard`, `branch-guard`, `no-sudo-guard`, `symlink-guard` |
17
+ | Data Protection | 5 | `block-database-wipe`, `secret-guard`, `hardcoded-secret-detector` |
18
+ | Git Safety | 11 | `git-config-guard`, `no-verify-blocker`, `push-requires-test-pass` |
19
+ | Auto-Approve (PreToolUse) | 11 | `auto-approve-readonly`, `auto-approve-build`, `auto-approve-docker` |
20
+ | Auto-Approve (PermissionRequest) | 7 | `allow-git-hooks-dir`, `allow-protected-dirs`, `edit-always-allow` |
21
+ | Code Quality | 10 | `syntax-check`, `diff-size-guard`, `test-deletion-guard` |
22
+ | Security | 10 | `credential-file-cat-guard`, `credential-exfil-guard`, `prompt-injection-guard` |
23
+ | Deploy | 4 | `deploy-guard`, `no-deploy-friday`, `work-hours-guard` |
24
+ | Monitoring & Cost | 13 | `context-monitor`, `cost-tracker`, `loop-detector`, `edit-error-counter` |
25
+ | Utility | 17 | `comment-strip`, `session-handoff`, `auto-checkpoint`, `edit-retry-loop-guard` |
15
26
 
16
- ## Quality (8)
17
- branch-name-check, commit-message-check, commit-quality-gate, edit-guard, enforce-tests, large-file-guard, todo-check, verify-before-commit
27
+ ## Popular Hooks
18
28
 
19
- ## Recovery (3)
20
- auto-checkpoint, auto-snapshot, session-checkpoint
29
+ - **`auto-approve-readonly`** — Skip prompts for `cat`, `ls`, `grep`, `git status`
30
+ - **`destructive-guard`** — Block `rm -rf`, `git reset --hard`
31
+ - **`credential-file-cat-guard`** — Block reading `.netrc`, `.npmrc`, `.cargo/credentials`
32
+ - **`push-requires-test-pass`** — Block `git push main` without passing tests
33
+ - **`context-monitor`** — Warn at 40/25/20/15% context remaining
21
34
 
22
- ## UX (9)
23
- cost-tracker, dependency-audit, diff-size-guard, hook-debug-wrapper, loop-detector, notify-waiting, read-before-edit, session-handoff, tmp-cleanup
35
+ ## Guides
36
+
37
+ - [Auto-Approve Guide](https://yurukusa.github.io/cc-safe-setup/auto-approve-guide.html)
38
+ - [Credential Protection](https://yurukusa.github.io/cc-safe-setup/prevent-credential-leak.html)
39
+ - [OWASP MCP Top 10 Defense](https://yurukusa.github.io/cc-safe-setup/owasp-mcp-hooks.html)
40
+ - [COOKBOOK](../COOKBOOK.md)
24
41
 
25
42
  ## Write Your Own
43
+
26
44
  See [CONTRIBUTING.md](../CONTRIBUTING.md).
@@ -0,0 +1,44 @@
1
+ #!/bin/bash
2
+ # credential-file-cat-guard.sh — Block cat/read of package manager credential files
3
+ #
4
+ # Solves: Agent displays full credential files in conversation
5
+ # (#34819 — cat ~/.netrc, ~/.npmrc, ~/.cargo/credentials.toml displayed all tokens)
6
+ #
7
+ # Complements credential-exfil-guard.sh which blocks hunting patterns.
8
+ # This hook blocks direct read of known credential files that the
9
+ # exfil guard misses: .netrc, .npmrc, .cargo/credentials, .docker/config.json, etc.
10
+ #
11
+ # Usage: Add to settings.json as a PreToolUse hook
12
+ #
13
+ # {
14
+ # "hooks": {
15
+ # "PreToolUse": [{
16
+ # "matcher": "Bash",
17
+ # "hooks": [{ "type": "command", "command": "~/.claude/hooks/credential-file-cat-guard.sh" }]
18
+ # }]
19
+ # }
20
+ # }
21
+
22
+ INPUT=$(cat)
23
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
24
+
25
+ [ -z "$COMMAND" ] && exit 0
26
+
27
+ # Known credential files that contain tokens/passwords
28
+ CRED_FILES='\.netrc|\.npmrc|\.yarnrc\.yml|\.cargo/credentials|\.docker/config\.json|\.kube/config|\.config/gh/hosts\.yml|\.nuget/NuGet\.Config|\.m2/settings\.xml|\.gradle/gradle\.properties|\.pypirc|\.gem/credentials|\.config/pip/pip\.conf|\.bowerrc|\.composer/auth\.json'
29
+
30
+ # Block cat/head/tail/less/more/grep reading credential files
31
+ if echo "$COMMAND" | grep -qE "(cat|head|tail|less|more|bat)\s+[^\|;]*($CRED_FILES)"; then
32
+ FILE=$(echo "$COMMAND" | grep -oE "[~\/][^\s;|]*($CRED_FILES)[^\s;|]*" | head -1)
33
+ echo "BLOCKED: Reading credential file: $FILE" >&2
34
+ echo " These files contain authentication tokens. Use environment variables instead." >&2
35
+ exit 2
36
+ fi
37
+
38
+ # Block grep searching inside credential files
39
+ if echo "$COMMAND" | grep -qE "grep\s+.*\s+[^\|;]*($CRED_FILES)"; then
40
+ echo "BLOCKED: Searching inside credential file" >&2
41
+ exit 2
42
+ fi
43
+
44
+ exit 0
@@ -0,0 +1,40 @@
1
+ #!/bin/bash
2
+ # edit-retry-loop-guard.sh — Detect Edit tool stuck retrying the same file
3
+ #
4
+ # Solves: Edit tool path contamination in long sessions
5
+ # (#35576 — Edit gets "stuck" targeting wrong file, retrying 15+ times)
6
+ #
7
+ # Tracks consecutive Edit failures on the same file. After 3 failures,
8
+ # warns the model to verify the file path.
9
+
10
+ INPUT=$(cat)
11
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
12
+
13
+ [ "$TOOL" = "Edit" ] || exit 0
14
+
15
+ FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
16
+ EXIT_CODE=$(echo "$INPUT" | jq -r '.tool_output.exit_code // empty' 2>/dev/null)
17
+ OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // empty' 2>/dev/null)
18
+
19
+ [ -z "$FILE" ] && exit 0
20
+
21
+ STATE_FILE="/tmp/.cc-edit-retry-$(echo "$FILE" | md5sum | cut -c1-8)"
22
+
23
+ # Check if edit failed (non-zero exit or "no changes" in output)
24
+ if [ "$EXIT_CODE" != "0" ] || echo "$OUTPUT" | grep -qiE 'no changes|not found|old_string.*not.*found'; then
25
+ COUNT=1
26
+ [ -f "$STATE_FILE" ] && COUNT=$(( $(cat "$STATE_FILE") + 1 ))
27
+ echo "$COUNT" > "$STATE_FILE"
28
+
29
+ if [ "$COUNT" -ge 3 ]; then
30
+ echo "WARNING: Edit has failed $COUNT times on: $FILE" >&2
31
+ echo " Verify the file path is correct. Use Read to check the current content." >&2
32
+ echo " The file may have been moved, renamed, or the content changed." >&2
33
+ rm -f "$STATE_FILE"
34
+ fi
35
+ else
36
+ # Success — reset counter
37
+ rm -f "$STATE_FILE"
38
+ fi
39
+
40
+ exit 0
@@ -0,0 +1,64 @@
1
+ #!/bin/bash
2
+ # git-checkout-safety-guard.sh — Prevent file loss from careless branch switching
3
+ #
4
+ # Solves: git checkout to another branch removes files that only exist
5
+ # on the current branch, causing data loss (#37150)
6
+ #
7
+ # The pattern:
8
+ # 1. User works on feature branch with new files
9
+ # 2. Agent runs "git checkout master" — files disappear from working tree
10
+ # 3. Agent runs "git branch -D feature" — files unrecoverable
11
+ #
12
+ # This hook blocks git checkout/switch when:
13
+ # - There are uncommitted changes (safety baseline)
14
+ # - The command includes branch deletion (-D, -d) of a non-merged branch
15
+ #
16
+ # Usage: PreToolUse hook on "Bash"
17
+ #
18
+ # {
19
+ # "hooks": {
20
+ # "PreToolUse": [{
21
+ # "matcher": "Bash",
22
+ # "hooks": [{ "type": "command", "command": "~/.claude/hooks/git-checkout-safety-guard.sh" }]
23
+ # }]
24
+ # }
25
+ # }
26
+
27
+ INPUT=$(cat)
28
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
29
+ [ -z "$COMMAND" ] && exit 0
30
+
31
+ # === Check 1: git checkout/switch to different branch with uncommitted changes ===
32
+ if echo "$COMMAND" | grep -qE 'git\s+(checkout|switch)\s+[a-zA-Z]'; then
33
+ # Check for uncommitted changes
34
+ if git status --porcelain 2>/dev/null | grep -q '^'; then
35
+ echo "BLOCKED: git checkout with uncommitted changes" >&2
36
+ echo " Commit or stash your changes first to avoid data loss." >&2
37
+ exit 2
38
+ fi
39
+ fi
40
+
41
+ # === Check 2: git branch -D (force delete — uppercase only) ===
42
+ if echo "$COMMAND" | grep -qE 'git\s+branch\s+-D\s'; then
43
+ BRANCH=$(echo "$COMMAND" | grep -oE 'git\s+branch\s+-[dD]\s+(\S+)' | awk '{print $NF}')
44
+ echo "BLOCKED: Destructive branch deletion: $BRANCH" >&2
45
+ echo " Use 'git branch -d' (lowercase) for safe deletion (checks merge status)." >&2
46
+ echo " Force deletion (-D) can cause unrecoverable data loss." >&2
47
+ exit 2
48
+ fi
49
+
50
+ # === Check 3: git checkout -- . (discard all changes) ===
51
+ if echo "$COMMAND" | grep -qE 'git\s+checkout\s+--\s+\.'; then
52
+ echo "BLOCKED: git checkout -- . discards ALL uncommitted changes" >&2
53
+ echo " Use 'git stash' to save changes, or specify individual files." >&2
54
+ exit 2
55
+ fi
56
+
57
+ # === Check 4: git checkout + branch -D in same command (the #37150 pattern) ===
58
+ if echo "$COMMAND" | grep -qE 'git\s+checkout.*&&.*git\s+branch\s+-D'; then
59
+ echo "BLOCKED: checkout + branch deletion is a data loss pattern" >&2
60
+ echo " Files that only exist on the deleted branch will be lost forever." >&2
61
+ exit 2
62
+ fi
63
+
64
+ exit 0
@@ -9,6 +9,8 @@
9
9
  #
10
10
  # Without this, hooks fail with "Permission denied" errors.
11
11
  # See: github.com/anthropics/claude-code/issues/38901
12
+ # github.com/anthropics/claude-code/issues/39777
13
+ # github.com/anthropics/claude-code/issues/39798
12
14
  #
13
15
  # TRIGGER: SessionStart MATCHER: ""
14
16
  # ================================================================
@@ -0,0 +1,94 @@
1
+ #!/bin/bash
2
+ # plan-mode-enforcer.sh — Hard-enforce Plan Mode read-only constraint
3
+ #
4
+ # Solves: Plan Mode restrictions bypassed — model performs write/execution
5
+ # operations instead of writing a plan first (#39713)
6
+ #
7
+ # Root cause: Plan Mode is only a system-reminder, easily overridden by
8
+ # tool instructions. This hook enforces it at the tool permission layer.
9
+ #
10
+ # How it works:
11
+ # - Checks if a state file indicates plan mode is active
12
+ # - If active, blocks all write operations (Edit, Write, Bash with side effects)
13
+ # - Allows read-only operations (Read, Glob, Grep, git status, etc.)
14
+ #
15
+ # Enable: touch /tmp/.cc-plan-mode-active
16
+ # Disable: rm /tmp/.cc-plan-mode-active
17
+ #
18
+ # Or use the companion SessionStart hook to auto-detect plan mode.
19
+ #
20
+ # Usage: PreToolUse hook (matcher: "")
21
+ #
22
+ # {
23
+ # "hooks": {
24
+ # "PreToolUse": [{
25
+ # "matcher": "",
26
+ # "hooks": [{ "type": "command", "command": "~/.claude/hooks/plan-mode-enforcer.sh" }]
27
+ # }]
28
+ # }
29
+ # }
30
+
31
+ STATE_FILE="/tmp/.cc-plan-mode-active"
32
+ [ -f "$STATE_FILE" ] || exit 0
33
+
34
+ INPUT=$(cat)
35
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
36
+
37
+ # Always allow read-only tools
38
+ case "$TOOL" in
39
+ Read|Glob|Grep)
40
+ exit 0
41
+ ;;
42
+ esac
43
+
44
+ # Block write tools entirely
45
+ case "$TOOL" in
46
+ Edit|Write|NotebookEdit)
47
+ echo "BLOCKED: Plan Mode active — write operations are not allowed" >&2
48
+ echo " Write your implementation plan first, then exit plan mode." >&2
49
+ exit 2
50
+ ;;
51
+ esac
52
+
53
+ # For Bash, allow read-only commands only
54
+ if [ "$TOOL" = "Bash" ]; then
55
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
56
+ [ -z "$COMMAND" ] && exit 0
57
+
58
+ # Extract base command
59
+ BASE=$(echo "$COMMAND" | awk '{print $1}' | sed 's|.*/||')
60
+
61
+ # Allowlist: read-only commands
62
+ case "$BASE" in
63
+ cat|head|tail|less|more|wc|grep|rg|ag|find|locate|\
64
+ ls|ll|dir|tree|stat|file|which|whereis|type|realpath|\
65
+ date|uptime|uname|hostname|whoami|id|env|printenv|pwd|\
66
+ df|du|free|top|ps|pgrep|jq|yq|curl|wget)
67
+ exit 0
68
+ ;;
69
+ esac
70
+
71
+ # Allow read-only git commands
72
+ if echo "$COMMAND" | grep -qE '^\s*git\s+(status|log|diff|show|branch|remote|tag\s+-l|blame|shortlog|describe|rev-parse|ls-files|ls-tree)\b'; then
73
+ exit 0
74
+ fi
75
+
76
+ # Allow npm/pip read-only
77
+ if echo "$COMMAND" | grep -qE '^\s*(npm\s+(ls|list|info|view|outdated)|pip\s+(list|show|freeze)|cargo\s+(tree|doc))'; then
78
+ exit 0
79
+ fi
80
+
81
+ # Block everything else in Bash during plan mode
82
+ echo "BLOCKED: Plan Mode active — command execution not allowed: $BASE" >&2
83
+ echo " Only read-only commands are permitted. Write your plan first." >&2
84
+ exit 2
85
+ fi
86
+
87
+ # Block Agent tool (sub-agents can bypass plan mode)
88
+ if [ "$TOOL" = "Agent" ]; then
89
+ echo "BLOCKED: Plan Mode active — sub-agent creation not allowed" >&2
90
+ exit 2
91
+ fi
92
+
93
+ # Allow other tools (TaskCreate, etc.)
94
+ exit 0
@@ -42,11 +42,16 @@ if [[ "$TOOL" == "Edit" || "$TOOL" == "Write" ]]; then
42
42
  exit 2
43
43
  fi
44
44
  done
45
- # Block writing to any ~/.ssh/ or ~/.aws/ files
45
+ # Block writing to any ~/.ssh/ or ~/.aws/ or project .kiro/ files
46
46
  if [[ "$FILE" == "${HOME_DIR}/.ssh/"* || "$FILE" == "${HOME_DIR}/.aws/"* ]]; then
47
47
  echo "BLOCKED: Cannot modify files in ${FILE%/*}/" >&2
48
48
  exit 2
49
49
  fi
50
+ # Protect .kiro/ directory (#40139 — Claude runtime silently deletes .kiro/)
51
+ if [[ "$FILE" == *"/.kiro/"* ]]; then
52
+ echo "BLOCKED: Cannot modify .kiro/ directory" >&2
53
+ exit 2
54
+ fi
50
55
  fi
51
56
 
52
57
  # === Check 2: Bash commands that modify dotfiles ===
@@ -0,0 +1,24 @@
1
+ #!/bin/bash
2
+ # push-requires-test-pass-record.sh — Record when tests pass (companion to push-requires-test-pass.sh)
3
+ #
4
+ # PostToolUse hook that detects successful test runs and records the timestamp.
5
+ # The PreToolUse companion then checks this record before allowing git push.
6
+ #
7
+ # Detected test commands: npm test, pytest, cargo test, go test, make test, etc.
8
+
9
+ INPUT=$(cat)
10
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
11
+ EXIT_CODE=$(echo "$INPUT" | jq -r '.tool_output.exit_code // .exit_code // empty' 2>/dev/null)
12
+
13
+ [ -z "$COMMAND" ] && exit 0
14
+
15
+ # Only record on exit code 0 (success)
16
+ [ "$EXIT_CODE" = "0" ] || exit 0
17
+
18
+ # Detect test commands
19
+ if echo "$COMMAND" | grep -qE '(npm\s+test|npx\s+jest|npx\s+vitest|npx\s+mocha|pytest|python\s+-m\s+pytest|cargo\s+test|go\s+test|make\s+test|gradle\s+test|mvn\s+test|bundle\s+exec\s+rspec|php\s+artisan\s+test|dotnet\s+test|bash\s+test\.sh)'; then
20
+ STATE_FILE="/tmp/.cc-test-pass-$(pwd | md5sum | cut -c1-8)"
21
+ date +%s > "$STATE_FILE"
22
+ fi
23
+
24
+ exit 0
@@ -0,0 +1,64 @@
1
+ #!/bin/bash
2
+ # push-requires-test-pass.sh — Block git push to main/production without test verification
3
+ #
4
+ # Solves: Agent pushes broken code to production without running tests
5
+ # (#36673 — pushed broken code 4 times, crashed live SaaS application)
6
+ #
7
+ # How it works:
8
+ # 1. PostToolUse companion records when tests pass (creates state file)
9
+ # 2. This PreToolUse hook blocks git push to protected branches unless tests passed
10
+ #
11
+ # Requires companion hook: push-requires-test-pass-record.sh (PostToolUse)
12
+ #
13
+ # Usage: Add BOTH hooks to settings.json
14
+ #
15
+ # {
16
+ # "hooks": {
17
+ # "PreToolUse": [{
18
+ # "matcher": "Bash",
19
+ # "hooks": [{ "type": "command", "command": "~/.claude/hooks/push-requires-test-pass.sh" }]
20
+ # }],
21
+ # "PostToolUse": [{
22
+ # "matcher": "Bash",
23
+ # "hooks": [{ "type": "command", "command": "~/.claude/hooks/push-requires-test-pass-record.sh" }]
24
+ # }]
25
+ # }
26
+ # }
27
+
28
+ INPUT=$(cat)
29
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
30
+
31
+ [ -z "$COMMAND" ] && exit 0
32
+
33
+ # Only check git push commands
34
+ echo "$COMMAND" | grep -qE '^\s*git\s+push\b' || exit 0
35
+
36
+ # Protected branches
37
+ PROTECTED='main|master|production|prod|release|deploy'
38
+
39
+ # Check if pushing to a protected branch
40
+ if echo "$COMMAND" | grep -qE "git\s+push\s+\S+\s+($PROTECTED)\b|git\s+push\s+($PROTECTED)\b|git\s+push\s*$"; then
41
+ STATE_FILE="/tmp/.cc-test-pass-$(pwd | md5sum | cut -c1-8)"
42
+
43
+ if [ ! -f "$STATE_FILE" ]; then
44
+ echo "BLOCKED: git push to protected branch without test verification" >&2
45
+ echo " Run your test suite first. Tests must pass before pushing." >&2
46
+ echo " Protected branches: main, master, production, prod, release, deploy" >&2
47
+ exit 2
48
+ fi
49
+
50
+ # Check if test pass is recent (within last 30 minutes)
51
+ if [ -f "$STATE_FILE" ]; then
52
+ PASS_TIME=$(cat "$STATE_FILE" 2>/dev/null)
53
+ NOW=$(date +%s)
54
+ AGE=$(( NOW - PASS_TIME ))
55
+ if [ "$AGE" -gt 1800 ]; then
56
+ echo "BLOCKED: Test pass record is stale ($(( AGE / 60 )) minutes old)" >&2
57
+ echo " Re-run tests before pushing to protected branch." >&2
58
+ rm -f "$STATE_FILE"
59
+ exit 2
60
+ fi
61
+ fi
62
+ fi
63
+
64
+ exit 0
@@ -0,0 +1,96 @@
1
+ #!/bin/bash
2
+ # shell-wrapper-guard.sh — Detect destructive commands hidden in shell wrappers
3
+ #
4
+ # Solves: Bypass vectors that evade destructive-guard by wrapping commands:
5
+ # sh -c "rm -rf /"
6
+ # bash -c "git reset --hard"
7
+ # python3 -c "import os; os.system('rm -rf ~')"
8
+ # perl -e "system('rm -rf /')"
9
+ # ruby -e "system('rm -rf /')"
10
+ # node -e "require('child_process').execSync('rm -rf /')"
11
+ #
12
+ # Complements destructive-guard.sh which checks direct commands.
13
+ # This hook unwraps interpreter one-liners and checks the inner command.
14
+ #
15
+ # Usage: PreToolUse hook on "Bash"
16
+
17
+ INPUT=$(cat)
18
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
19
+ [ -z "$COMMAND" ] && exit 0
20
+
21
+ # Destructive patterns to detect inside wrappers
22
+ DESTRUCT_PATTERN='rm\s+-[rf]*\s+[/~]|rm\s+-[rf]*\s+\.\.|git\s+reset\s+--hard|git\s+clean\s+-[fd]+|git\s+checkout\s+\.|mkfs\.|dd\s+if=|>\s*/dev/sd|chmod\s+777\s+/'
23
+
24
+ # === Check 1: sh/bash -c wrappers ===
25
+ if echo "$COMMAND" | grep -qE '(sh|bash|zsh|dash)\s+-c\s+'; then
26
+ INNER=$(echo "$COMMAND" | sed -E "s/.*(sh|bash|zsh|dash)\s+-c\s+['\"]?//" | sed "s/['\"]?\s*$//")
27
+ if echo "$INNER" | grep -qE "$DESTRUCT_PATTERN"; then
28
+ echo "BLOCKED: Destructive command hidden in shell wrapper" >&2
29
+ echo " Detected: $INNER" >&2
30
+ exit 2
31
+ fi
32
+ fi
33
+
34
+ # === Check 2: Python one-liners ===
35
+ if echo "$COMMAND" | grep -qE 'python[23]?\s+-c\s+'; then
36
+ INNER=$(echo "$COMMAND" | sed -E "s/.*python[23]?\s+-c\s+['\"]?//" | sed "s/['\"]?\s*$//")
37
+ if echo "$INNER" | grep -qiE "os\.system\(.*($DESTRUCT_PATTERN)|subprocess\.(run|call|Popen)\(.*($DESTRUCT_PATTERN)|shutil\.rmtree\s*\(\s*['\"/~]"; then
38
+ echo "BLOCKED: Destructive command in Python one-liner" >&2
39
+ exit 2
40
+ fi
41
+ fi
42
+
43
+ # === Check 3: Perl/Ruby one-liners ===
44
+ if echo "$COMMAND" | grep -qE '(perl|ruby)\s+-e\s+'; then
45
+ INNER=$(echo "$COMMAND" | sed -E "s/.*(perl|ruby)\s+-e\s+['\"]?//" | sed "s/['\"]?\s*$//")
46
+ if echo "$INNER" | grep -qE "system\(.*($DESTRUCT_PATTERN)|exec\(.*($DESTRUCT_PATTERN)"; then
47
+ echo "BLOCKED: Destructive command in interpreter one-liner" >&2
48
+ exit 2
49
+ fi
50
+ fi
51
+
52
+ # === Check 4: Node.js one-liners ===
53
+ if echo "$COMMAND" | grep -qE 'node\s+-e\s+'; then
54
+ INNER=$(echo "$COMMAND" | sed -E "s/.*node\s+-e\s+['\"]?//" | sed "s/['\"]?\s*$//")
55
+ if echo "$INNER" | grep -qE "execSync\(.*($DESTRUCT_PATTERN)|exec\(.*($DESTRUCT_PATTERN)"; then
56
+ echo "BLOCKED: Destructive command in Node.js one-liner" >&2
57
+ exit 2
58
+ fi
59
+ fi
60
+
61
+ # === Check 5: Nested wrappers (sh -c "bash -c 'rm -rf /'") ===
62
+ if echo "$COMMAND" | grep -qE '(sh|bash)\s+-c\s+.*(sh|bash)\s+-c'; then
63
+ if echo "$COMMAND" | grep -qE "$DESTRUCT_PATTERN"; then
64
+ echo "BLOCKED: Nested shell wrapper with destructive command" >&2
65
+ exit 2
66
+ fi
67
+ fi
68
+
69
+ # === Check 6: Pipe to shell (echo "rm -rf /" | sh) ===
70
+ if echo "$COMMAND" | grep -qE '\|\s*(sh|bash|zsh)\s*$'; then
71
+ # Extract the piped content
72
+ PIPED=$(echo "$COMMAND" | sed -E 's/\s*\|\s*(sh|bash|zsh)\s*$//')
73
+ if echo "$PIPED" | grep -qE "$DESTRUCT_PATTERN"; then
74
+ echo "BLOCKED: Destructive command piped to shell" >&2
75
+ exit 2
76
+ fi
77
+ fi
78
+
79
+ # === Check 7: Here-string to shell (bash <<< "rm -rf /") ===
80
+ if echo "$COMMAND" | grep -qE '(sh|bash|zsh)\s+<<<\s+'; then
81
+ INNER=$(echo "$COMMAND" | sed -E "s/.*(sh|bash|zsh)\s+<<<\s+['\"]?//" | sed "s/['\"]?\s*$//")
82
+ if echo "$INNER" | grep -qE "$DESTRUCT_PATTERN"; then
83
+ echo "BLOCKED: Destructive command via here-string" >&2
84
+ exit 2
85
+ fi
86
+ fi
87
+
88
+ # === Check 8: env-based bypass (env VAR=val sh -c "$VAR") ===
89
+ if echo "$COMMAND" | grep -qE '^\s*env\s+.*\s+(sh|bash)\s+-c'; then
90
+ if echo "$COMMAND" | grep -qE "$DESTRUCT_PATTERN"; then
91
+ echo "BLOCKED: Destructive command via env wrapper" >&2
92
+ exit 2
93
+ fi
94
+ fi
95
+
96
+ exit 0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cc-safe-setup",
3
- "version": "29.6.27",
4
- "description": "One command to make Claude Code safe. 507 example hooks + 8 built-in. 56 CLI commands. 7341 tests. Works with Auto Mode.",
3
+ "version": "29.6.29",
4
+ "description": "One command to make Claude Code safe. 514 example hooks + 8 built-in. 56 CLI commands. 7564 tests. Works with Auto Mode.",
5
5
  "main": "index.mjs",
6
6
  "bin": {
7
7
  "cc-safe-setup": "index.mjs"
package/scripts.json CHANGED
@@ -1 +1,10 @@
1
- {"destructive-guard": "#!/bin/bash\n# ================================================================\n# destructive-guard.sh \u2014 Destructive Command Blocker\n# ================================================================\n# PURPOSE:\n# Blocks dangerous shell commands that can cause irreversible damage.\n# Catches rm -rf on sensitive paths, git reset --hard, git clean -fd,\n# and other destructive operations before they execute.\n#\n# Built after a real incident where rm -rf on a pnpm project\n# followed NTFS junctions and deleted an entire C:\\Users directory.\n# (GitHub Issue #36339)\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# WHAT IT BLOCKS (exit 2):\n# - rm -rf / rm -r on root, home, or parent paths (/, ~, .., /home, /etc)\n# - git reset --hard\n# - git clean -fd / git clean -fdx\n# - chmod -R 777 on sensitive paths\n# - find ... -delete on broad patterns\n#\n# WHAT IT ALLOWS (exit 0):\n# - rm -rf on specific project subdirectories (node_modules, dist, build)\n# - git reset --soft, git reset HEAD\n# - All non-destructive commands\n#\n# CONFIGURATION:\n# CC_ALLOW_DESTRUCTIVE=1 \u2014 disable this guard (not recommended)\n# CC_SAFE_DELETE_DIRS \u2014 colon-separated list of safe-to-delete dirs\n# default: \"node_modules:dist:build:.cache:__pycache__:coverage\"\n#\n# NOTE: On Windows/WSL2, rm -rf can follow NTFS junctions (symlinks)\n# and delete far more than intended. This guard is especially critical\n# on WSL2 environments.\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# Allow override (not recommended)\nif [[ \"${CC_ALLOW_DESTRUCTIVE:-0}\" == \"1\" ]]; then\n exit 0\nfi\n\n# Log function \u2014 records blocked commands for audit\nlog_block() {\n local reason=\"$1\"\n local logfile=\"${CC_BLOCK_LOG:-$HOME/.claude/blocked-commands.log}\"\n mkdir -p \"$(dirname \"$logfile\")\" 2>/dev/null\n echo \"[$(date -Iseconds)] BLOCKED: $reason | cmd: $COMMAND\" >> \"$logfile\" 2>/dev/null\n}\n\n# Safe directories that can be deleted\nSAFE_DIRS=\"${CC_SAFE_DELETE_DIRS:-node_modules:dist:build:.cache:__pycache__:coverage:.next:.nuxt:tmp}\"\n\n# --- Check 0: --no-preserve-root ---\nif echo \"$COMMAND\" | grep -qE \"rm\\\\s.*\\\\-\\\\-no-preserve-root\"; then\n echo \"BLOCKED: --no-preserve-root detected.\" >&2\n exit 2\nfi\n\n# --- Check 1: rm -rf on dangerous paths ---\nif echo \"$COMMAND\" | grep -qE 'rm\\s+(-[rf]+\\s+)*(\\/$|\\/\\s|\\/[^a-z]|\\/home|\\/etc|\\/usr|\\/var|\\/mnt|~\\/|~\\s*$|\\.\\.\\/|\\.\\.\\s*$|\\.\\s*$|\\.\\/\\s*$)'; then\n # Exception: safe directories\n SAFE=0\n IFS=':' read -ra DIRS <<< \"$SAFE_DIRS\"\n for dir in \"${DIRS[@]}\"; do\n if echo \"$COMMAND\" | grep -qE \"rm\\s+.*${dir}\\s*$|rm\\s+.*${dir}/\"; then\n SAFE=1\n break\n fi\n done\n\n # Check for mounted filesystems inside the target (NFS, Docker, bind mounts)\n # Why: GitHub #36640 \u2014 rm -rf on a dir with NFS mount deleted production data\n if (( SAFE == 0 )); then\n # Extract the target path from the rm command\n TARGET_PATH=$(echo \"$COMMAND\" | grep -oP 'rm\\s+(-[rf]+\\s+)*\\K\\S+')\n if [ -n \"$TARGET_PATH\" ] && command -v findmnt &>/dev/null; then\n if findmnt -n -o TARGET --submounts \"$TARGET_PATH\" 2>/dev/null | grep -q .; then\n log_block \"rm on path with mounted filesystem\"\n echo \"BLOCKED: Target contains a mounted filesystem (NFS, Docker, bind).\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Unmount the filesystem first, then retry.\" >&2\n exit 2\n fi\n fi\n fi\n\n if (( SAFE == 0 )); then\n log_block \"rm on sensitive path\"\n echo \"BLOCKED: rm on sensitive path detected.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"This command targets a sensitive directory that could cause\" >&2\n echo \"irreversible data loss. On WSL2, rm -rf can follow NTFS\" >&2\n echo \"junctions and delete far beyond the target directory.\" >&2\n echo \"\" >&2\n echo \"If you need to delete a specific subdirectory, target it directly:\" >&2\n echo \" rm -rf ./specific-folder\" >&2\n exit 2\n fi\nfi\n\n# --- Check 2: git reset --hard ---\n# Only match when git is the actual command, not inside strings/arguments\nif echo \"$COMMAND\" | grep -qE '^\\s*git\\s+reset\\s+--hard|;\\s*git\\s+reset\\s+--hard|&&\\s*git\\s+reset\\s+--hard|\\|\\|\\s*git\\s+reset\\s+--hard'; then\n log_block \"git reset --hard\"\n echo \"BLOCKED: git reset --hard discards all uncommitted changes.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Consider: git stash, or git reset --soft to keep changes staged.\" >&2\n exit 2\nfi\n\n# --- Check 3: git clean -fd ---\nif echo \"$COMMAND\" | grep -qE '^\\s*git\\s+clean\\s+-[a-z]*[fd]|;\\s*git\\s+clean|&&\\s*git\\s+clean|\\|\\|\\s*git\\s+clean'; then\n log_block \"git clean\"\n echo \"BLOCKED: git clean removes untracked files permanently.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Consider: git clean -n (dry run) first to see what would be deleted.\" >&2\n exit 2\nfi\n\n# --- Check 4: chmod 777 on broad paths ---\nif echo \"$COMMAND\" | grep -qE 'chmod\\s+(-R\\s+)?777\\s+(\\/|~|\\.)'; then\n echo \"BLOCKED: chmod 777 on broad path is a security risk.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n exit 2\nfi\n\n# --- Check 5: find -delete on broad patterns ---\nif echo \"$COMMAND\" | grep -qE 'find\\s+(\\/|~|\\.\\.)\\s.*-delete'; then\n echo \"BLOCKED: find -delete on broad path risks mass deletion.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Consider: find ... -print first to verify what matches.\" >&2\n exit 2\nfi\n\n# --- Check 6: sudo with dangerous commands ---\nif echo \"$COMMAND\" | grep -qE '^\\s*sudo\\s+(rm\\s+-[rf]|chmod\\s+(-R\\s+)?777|dd\\s+if=|mkfs)'; then\n log_block \"sudo with dangerous command\"\n echo \"BLOCKED: sudo with dangerous command detected.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Running destructive commands with sudo amplifies the damage.\" >&2\n echo \"Review the command carefully before proceeding.\" >&2\n exit 2\nfi\n\n\n# --- Check 7: PowerShell Remove-Item (Windows/WSL2) ---\n# Real incident: GitHub #37331 \u2014 destroyed entire repo\n# Skip if command is git commit (message text triggers false positive)\nif echo \"$COMMAND\" | grep -qE '^\\s*(git\\s+commit|echo\\s|printf\\s|cat\\s)'; then\n : # string output commands mentioning PS commands are not destructive\nelif echo \"$COMMAND\" | grep -qiE 'Remove-Item.*-Recurse.*-Force|Remove-Item.*-Force.*-Recurse|del\\s+/s\\s+/q|rd\\s+/s\\s+/q|rmdir\\s+/s\\s+/q'; then\n log_block \"PowerShell destructive command\"\n echo \"BLOCKED: Destructive PowerShell command detected.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Remove-Item with recursive force-delete can destroy entire directories\" >&2\n echo \"irreversibly. Target specific files instead.\" >&2\n exit 2\nfi\nif echo \"$COMMAND\" | grep -qE '(^|;|&&|\\|\\|)\\s*git\\s+(checkout|switch)\\s+.*(--force\\b|-f\\b|--discard-changes\\b)'; then\n log_block \"git checkout/switch --force\"\n echo \"BLOCKED: git checkout/switch with --force discards uncommitted changes.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Consider: git stash before switching, or use git switch without --force.\" >&2\n exit 2\nfi\nexit 0\n", "branch-guard": "#!/bin/bash\n# ================================================================\n# branch-guard.sh \u2014 Branch Push Protector\n# ================================================================\n# PURPOSE:\n# Prevents accidental git push to main/master branches AND\n# blocks force-push on ALL branches without explicit approval.\n#\n# Force-pushes rewrite history and can destroy teammates' work.\n# Protected branch pushes bypass code review.\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# WHAT IT BLOCKS (exit 2):\n# - git push origin main/master (any protected branch)\n# - git push --force (any branch \u2014 history rewriting)\n# - git push -f (short flag variant)\n# - git push --force-with-lease (still destructive)\n#\n# WHAT IT ALLOWS (exit 0):\n# - git push origin feature-branch (non-force)\n# - git push -u origin feature-branch\n# - All other git commands\n# - All non-git commands\n#\n# CONFIGURATION:\n# CC_PROTECT_BRANCHES \u2014 colon-separated list of protected branches\n# default: \"main:master\"\n# CC_ALLOW_FORCE_PUSH=1 \u2014 disable force-push protection\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# Only check git push commands\nif ! echo \"$COMMAND\" | grep -qE '^\\s*git\\s+push'; then\n exit 0\nfi\n\n# --- Check 1: Force push on ANY branch ---\nif [[ \"${CC_ALLOW_FORCE_PUSH:-0}\" != \"1\" ]]; then\n if echo \"$COMMAND\" | grep -qE 'git\\s+push\\s+.*(-f\\b|--force\\b|--force-with-lease\\b)'; then\n echo \"BLOCKED: Force push detected.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Force push rewrites remote history and can destroy\" >&2\n echo \"other people's work. This is almost never what you want.\" >&2\n echo \"\" >&2\n echo \"If you truly need to force push, set CC_ALLOW_FORCE_PUSH=1\" >&2\n exit 2\n fi\nfi\n\n# --- Check 2: Push to protected branches ---\nPROTECTED=\"${CC_PROTECT_BRANCHES:-main:master}\"\n\nBLOCKED=0\nIFS=':' read -ra BRANCHES <<< \"$PROTECTED\"\nfor branch in \"${BRANCHES[@]}\"; do\n if echo \"$COMMAND\" | grep -qwE \"origin\\s+${branch}|${branch}\\s|${branch}$\"; then\n BLOCKED=1\n break\n fi\ndone\n\nif (( BLOCKED == 1 )); then\n echo \"BLOCKED: Attempted push to protected branch.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Protected branches: $PROTECTED\" >&2\n echo \"\" >&2\n echo \"Push to a feature branch first, then create a pull request.\" >&2\n exit 2\nfi\n\nexit 0\n", "syntax-check": "#!/bin/bash\n# ================================================================\n# syntax-check.sh \u2014 Automatic Syntax Validation After Edits\n# ================================================================\n# PURPOSE:\n# Runs syntax checks immediately after Claude Code edits or\n# writes a file. Catches syntax errors before they propagate\n# into downstream failures.\n#\n# SUPPORTED LANGUAGES:\n# .py \u2014 python -m py_compile\n# .sh \u2014 bash -n\n# .bash \u2014 bash -n\n# .json \u2014 jq empty\n# .yaml \u2014 python3 yaml.safe_load (if PyYAML installed)\n# .yml \u2014 python3 yaml.safe_load (if PyYAML installed)\n# .js \u2014 node --check (if node installed)\n# .ts \u2014 npx tsc --noEmit (if tsc available) [EXPERIMENTAL]\n#\n# TRIGGER: PostToolUse\n# MATCHER: \"Edit|Write\"\n#\n# DESIGN PHILOSOPHY:\n# - Never blocks (always exit 0) \u2014 reports errors but doesn't\n# prevent the edit from completing\n# - Silent on success \u2014 only speaks up when something is wrong\n# - Fails open \u2014 if a checker isn't installed, silently skips\n#\n# BORN FROM:\n# Countless sessions where Claude Code introduced a syntax error,\n# continued working for 10+ tool calls, then hit a wall when\n# trying to run the broken file. Catching it immediately saves\n# context window and frustration.\n# ================================================================\n\nINPUT=$(cat)\nFILE_PATH=$(echo \"$INPUT\" | jq -r '.tool_input.file_path // empty' 2>/dev/null)\n\n# No file path = nothing to check\nif [[ -z \"$FILE_PATH\" || ! -f \"$FILE_PATH\" ]]; then\n exit 0\nfi\n\nEXT=\"${FILE_PATH##*.}\"\n\ncase \"$EXT\" in\n py)\n if python3 -m py_compile \"$FILE_PATH\" 2>&1; then\n : # silent on success\n else\n echo \"SYNTAX ERROR (Python): $FILE_PATH\" >&2\n fi\n ;;\n sh|bash)\n if bash -n \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (Shell): $FILE_PATH\" >&2\n fi\n ;;\n json)\n if command -v jq &>/dev/null; then\n if jq empty \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (JSON): $FILE_PATH\" >&2\n fi\n fi\n ;;\n yaml|yml)\n if python3 -c \"import yaml\" 2>/dev/null; then\n if python3 -c \"\nimport yaml, sys\nwith open(sys.argv[1]) as f:\n yaml.safe_load(f)\n\" \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (YAML): $FILE_PATH\" >&2\n fi\n fi\n ;;\n js)\n if command -v node &>/dev/null; then\n if node --check \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (JavaScript): $FILE_PATH\" >&2\n fi\n fi\n ;;\n ts)\n # EXPERIMENTAL: TypeScript check requires tsc in PATH\n if command -v npx &>/dev/null; then\n if npx tsc --noEmit \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (TypeScript) [experimental]: $FILE_PATH\" >&2\n fi\n fi\n ;;\n *)\n # Unknown extension \u2014 skip silently\n ;;\nesac\n\nexit 0\n", "context-monitor": "#!/bin/bash\n# ================================================================\n# context-monitor.sh \u2014 Context Window Remaining Capacity Monitor\n# ================================================================\n# PURPOSE:\n# Monitors how much context window remains during a Claude Code\n# session. Issues graduated warnings (CAUTION \u2192 WARNING \u2192 CRITICAL\n# \u2192 EMERGENCY) so you never get killed by context exhaustion.\n#\n# HOW IT WORKS:\n# 1. Reads Claude Code's debug log to extract actual token usage\n# 2. Falls back to tool-call-count estimation when debug logs\n# are unavailable\n# 3. Saves current % to /tmp/cc-context-pct (other scripts can\n# read this)\n# 4. At CRITICAL/EMERGENCY, writes an evacuation template to\n# your mission file so you can hand off state before /compact\n#\n# TRIGGER: PostToolUse (all tools)\n# MATCHER: \"\" (empty = every tool invocation)\n#\n# CONFIGURATION:\n# CC_CONTEXT_MISSION_FILE \u2014 path to your mission/state file\n# default: $HOME/mission.md\n#\n# THRESHOLDS (edit below to taste):\n# CAUTION = 40% \u2014 be mindful of consumption\n# WARNING = 25% \u2014 finish current task, save state\n# CRITICAL = 20% \u2014 run /compact immediately\n# EMERGENCY = 15% \u2014 stop everything, evacuate\n#\n# BORN FROM:\n# A session that hit 3% context remaining with no warning.\n# The agent died mid-task and all in-flight work was lost.\n# Never again.\n# ================================================================\n\nSTATE_FILE=\"/tmp/cc-context-state\"\nPCT_FILE=\"/tmp/cc-context-pct\"\nCOUNTER_FILE=\"/tmp/cc-context-monitor-count\"\nMISSION_FILE=\"${CC_CONTEXT_MISSION_FILE:-$HOME/mission.md}\"\n\n# Tool invocation counter (fallback estimator)\nCOUNT=$(cat \"$COUNTER_FILE\" 2>/dev/null || echo 0)\nCOUNT=$((COUNT + 1))\necho \"$COUNT\" > \"$COUNTER_FILE\"\n\n# Check every 3rd invocation to reduce overhead\n# (but always check in CRITICAL/EMERGENCY state)\nLAST_STATE=$(cat \"$STATE_FILE\" 2>/dev/null || echo \"normal\")\nif [ $((COUNT % 3)) -ne 0 ] && [ \"$LAST_STATE\" != \"critical\" ] && [ \"$LAST_STATE\" != \"emergency\" ]; then\n exit 0\nfi\n\n# --- Extract context % from Claude Code debug logs ---\nget_context_pct() {\n local debug_dir=\"$HOME/.claude/debug\"\n if [ ! -d \"$debug_dir\" ]; then\n echo \"\"\n return\n fi\n\n local latest\n latest=$(find \"$debug_dir\" -maxdepth 1 -name '*.txt' -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2)\n if [ -z \"$latest\" ]; then\n echo \"\"\n return\n fi\n\n # Parse the last autocompact entry for token counts\n local line\n line=$(grep 'autocompact:' \"$latest\" 2>/dev/null | tail -1)\n if [ -z \"$line\" ]; then\n echo \"\"\n return\n fi\n\n local tokens window\n tokens=$(echo \"$line\" | sed 's/.*tokens=\\([0-9]*\\).*/\\1/')\n window=$(echo \"$line\" | sed 's/.*effectiveWindow=\\([0-9]*\\).*/\\1/')\n\n if [ -n \"$tokens\" ] && [ -n \"$window\" ] && [ \"$window\" -gt 0 ] 2>/dev/null; then\n local pct\n pct=$(( (window - tokens) * 100 / window ))\n echo \"$pct\"\n else\n echo \"\"\n fi\n}\n\nCONTEXT_PCT=$(get_context_pct)\n\n# Fallback: estimate from tool call count when debug logs unavailable\n# Assumes ~180 tool calls fills ~100% of context (conservative)\nif [ -z \"$CONTEXT_PCT\" ]; then\n CONTEXT_PCT=$(( 100 - (COUNT * 100 / 180) ))\n if [ \"$CONTEXT_PCT\" -lt 0 ]; then CONTEXT_PCT=0; fi\n SOURCE=\"estimate\"\nelse\n SOURCE=\"debug\"\nfi\n\necho \"$CONTEXT_PCT\" > \"$PCT_FILE\"\n\nTIMESTAMP=$(date '+%Y-%m-%d %H:%M')\n\n# --- Evacuation template (with cooldown to prevent spam) ---\nEVAC_COOLDOWN_FILE=\"/tmp/cc-context-evac-last\"\nEVAC_COOLDOWN_SEC=1800 # 30 min cooldown between template generations\n\ngenerate_evacuation_template() {\n local level=\"$1\"\n\n # Cooldown check\n if [ -f \"$EVAC_COOLDOWN_FILE\" ]; then\n local last_ts now_ts diff\n last_ts=$(cat \"$EVAC_COOLDOWN_FILE\" 2>/dev/null || echo 0)\n now_ts=$(date +%s)\n diff=$((now_ts - last_ts))\n if [ \"$diff\" -lt \"$EVAC_COOLDOWN_SEC\" ]; then\n return\n fi\n fi\n\n # Don't add a new template if there's already an unfilled one\n if [ -f \"$MISSION_FILE\" ] && grep -q '\\[TODO\\]' \"$MISSION_FILE\" 2>/dev/null; then\n return\n fi\n\n date +%s > \"$EVAC_COOLDOWN_FILE\"\n\n # Create mission file directory if needed\n mkdir -p \"$(dirname \"$MISSION_FILE\")\"\n\n cat >> \"$MISSION_FILE\" << EVAC_EOF\n\n## Context Evacuation Template (${level} - ${TIMESTAMP})\n<!-- Auto-generated by context-monitor.sh. Fill in before /compact -->\n### Current Task\n- Task: [TODO]\n- Progress: [TODO]\n- Files being edited: [TODO]\n\n### Git State\n- Branch: [TODO]\n- Uncommitted changes: [TODO]\n\n### Next Action\n- Next command/action: [TODO]\nEVAC_EOF\n}\n\n# --- Graduated warnings ---\nif [ \"$CONTEXT_PCT\" -le 15 ]; then\n # EMERGENCY\n if [ \"$LAST_STATE\" != \"emergency\" ]; then\n echo \"emergency\" > \"$STATE_FILE\"\n generate_evacuation_template \"EMERGENCY\"\n fi\n echo \"\"\n echo \"EMERGENCY: Context remaining ${CONTEXT_PCT}% (${SOURCE})\"\n echo \"Run /compact IMMEDIATELY. Evacuation template written to ${MISSION_FILE}.\"\n echo \"1. Fill in the [TODO] fields in the template\"\n echo \"2. Run /compact\"\n echo \"3. If needed, restart and resume from mission file\"\n echo \"No further work allowed. Evacuate only.\"\n\nelif [ \"$CONTEXT_PCT\" -le 20 ]; then\n # CRITICAL\n if [ \"$LAST_STATE\" != \"critical\" ]; then\n echo \"critical\" > \"$STATE_FILE\"\n generate_evacuation_template \"CRITICAL\"\n fi\n echo \"\"\n echo \"CRITICAL: Context remaining ${CONTEXT_PCT}% (${SOURCE})\"\n echo \"Run /compact IMMEDIATELY. Evacuation template written to ${MISSION_FILE}.\"\n echo \"1. Save current task state to the template\"\n echo \"2. Run /compact\"\n\nelif [ \"$CONTEXT_PCT\" -le 25 ]; then\n # WARNING\n if [ \"$LAST_STATE\" != \"warning\" ]; then\n echo \"warning\" > \"$STATE_FILE\"\n echo \"\"\n echo \"WARNING: Context remaining ${CONTEXT_PCT}% (${SOURCE})\"\n echo \"Do not start new large tasks. Finish current work and save state.\"\n fi\n\nelif [ \"$CONTEXT_PCT\" -le 40 ]; then\n # CAUTION\n if [ \"$LAST_STATE\" != \"caution\" ]; then\n echo \"caution\" > \"$STATE_FILE\"\n echo \"\"\n echo \"CAUTION: Context remaining ${CONTEXT_PCT}% (${SOURCE})\"\n echo \"Be mindful of context consumption. Keep interactions concise.\"\n fi\nfi\n\nexit 0\n", "comment-strip": "#!/bin/bash\n# ================================================================\n# comment-strip.sh \u2014 Strip bash comments that break permissions\n# ================================================================\n# PURPOSE:\n# Claude Code sometimes adds comments to bash commands like:\n# # Check the diff\n# git diff HEAD~1\n# This breaks permission allowlists (e.g. Bash(git:*)) because\n# the matcher sees \"# Check the diff\" instead of \"git diff\".\n#\n# This hook strips leading comment lines and returns the clean\n# command via updatedInput, so permissions match correctly.\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# INCIDENT: GitHub Issue #29582 (18 reactions)\n# Users on linux/vscode report that bash comments added by Claude\n# cause permission prompts even when the command is allowlisted.\n#\n# HOW IT WORKS:\n# - Reads the command from tool_input\n# - Strips leading lines that start with #\n# - Strips trailing comments (everything after # on command lines)\n# - Returns updatedInput with the cleaned command\n# - Uses hookSpecificOutput.permissionDecision = \"allow\" only if\n# the command was modified (so it doesn't override other hooks)\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# Strip leading comment lines and empty lines\nCLEAN=$(echo \"$COMMAND\" | sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d')\n\n# If nothing changed, pass through\nif [[ \"$CLEAN\" == \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# If command is empty after stripping, don't modify\nif [[ -z \"$CLEAN\" ]]; then\n exit 0\nfi\n\n# Return cleaned command via hookSpecificOutput\n# permissionDecision is not set \u2014 let the normal permission flow handle it\n# We only modify the input so the permission matcher sees the real command\njq -n --arg cmd \"$CLEAN\" '{\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\",\n updatedInput: {\n command: $cmd\n }\n }\n}'\n", "cd-git-allow": "#!/bin/bash\n# ================================================================\n# cd-git-allow.sh \u2014 Auto-approve cd+git compound commands\n# ================================================================\n# PURPOSE:\n# Claude Code shows \"Compound commands with cd and git require\n# approval\" for commands like: cd /path && git log\n# This is safe in trusted project directories but causes\n# constant permission prompts.\n#\n# This hook auto-approves cd+git compounds when the git operation\n# is read-only (log, diff, status, branch, show, etc.)\n# Destructive git operations (push, reset, clean) are NOT\n# auto-approved \u2014 they still require manual approval.\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# INCIDENT: GitHub Issue #32985 (9 reactions)\n#\n# WHAT IT AUTO-APPROVES:\n# - cd /path && git log\n# - cd /path && git diff\n# - cd /path && git status\n# - cd /path && git branch\n# - cd /path && git show\n# - cd /path && git rev-parse\n#\n# WHAT IT DOES NOT APPROVE (still prompts):\n# - cd /path && git push\n# - cd /path && git reset --hard\n# - cd /path && git clean\n# - cd /path && git checkout (could discard changes)\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# Only handle cd + git compounds\nif ! echo \"$COMMAND\" | grep -qE '^\\s*cd\\s+.*&&\\s*git\\s'; then\n exit 0\nfi\n\n# Extract the git subcommand\nGIT_CMD=$(echo \"$COMMAND\" | grep -oP '&&\\s*git\\s+\\K\\S+')\n\n# Read-only git operations \u2014 safe to auto-approve\nSAFE_GIT=\"log diff status branch show rev-parse tag remote stash-list describe name-rev\"\n\nfor safe in $SAFE_GIT; do\n if [[ \"$GIT_CMD\" == \"$safe\" ]]; then\n jq -n '{\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\",\n permissionDecision: \"allow\",\n permissionDecisionReason: \"cd+git compound auto-approved (read-only git operation)\"\n }\n }'\n exit 0\n fi\ndone\n\n# Not a read-only git op \u2014 let normal permission flow handle it\nexit 0\n", "secret-guard": "#!/bin/bash\n# ================================================================\n# secret-guard.sh \u2014 Secret/Credential Leak Prevention\n# ================================================================\n# PURPOSE:\n# Prevents accidental exposure of secrets, API keys, and\n# credentials through git commits or shell output.\n#\n# Catches the most common ways secrets leak:\n# - git add .env (committing env files)\n# - git add credentials.json / *.pem / *.key\n# - echo $API_KEY or printenv (exposing secrets in output)\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# WHAT IT BLOCKS (exit 2):\n# - git add .env / .env.local / .env.production\n# - git add *credentials* / *secret* / *.pem / *.key\n# - git add -A or git add . when .env exists (warns)\n#\n# WHAT IT ALLOWS (exit 0):\n# - git add specific safe files\n# - Reading .env for application use (not committing)\n# - All non-git-add commands\n#\n# CONFIGURATION:\n# CC_SECRET_PATTERNS \u2014 colon-separated additional patterns to block\n# default: \".env:.env.local:.env.production:credentials:secret:*.pem:*.key:*.p12\"\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# --- Check 1: git add of secret files ---\nif echo \"$COMMAND\" | grep -qE '^\\s*git\\s+add'; then\n # Direct .env file staging\n if echo \"$COMMAND\" | grep -qiE 'git\\s+add\\s+.*\\.env(\\s|$|\\.|/)'; then\n echo \"BLOCKED: Attempted to stage .env file.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \".env files contain secrets and should never be committed.\" >&2\n echo \"Add .env to .gitignore instead.\" >&2\n exit 2\n fi\n\n # Credential/key files\n if echo \"$COMMAND\" | grep -qiE 'git\\s+add\\s+.*(credentials|\\.pem|\\.key|\\.p12|\\.pfx|id_rsa|id_ed25519)'; then\n echo \"BLOCKED: Attempted to stage credential/key file.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Key and credential files should never be committed to git.\" >&2\n echo \"Add them to .gitignore instead.\" >&2\n exit 2\n fi\n\n # git add -A or git add . when .env exists \u2014 warn but check\n if echo \"$COMMAND\" | grep -qE 'git\\s+add\\s+(-A|--all|\\.)(\\s|$)'; then\n # Check if .env exists in the current or project directory\n if [ -f \".env\" ] || [ -f \".env.local\" ] || [ -f \".env.production\" ]; then\n echo \"BLOCKED: 'git add .' with .env file present.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"An .env file exists in this directory. 'git add .' would stage it.\" >&2\n echo \"Add specific files instead: git add src/ lib/ package.json\" >&2\n echo \"Or add .env to .gitignore first.\" >&2\n exit 2\n fi\n fi\nfi\n\nexit 0\n", "api-error-alert": "#!/bin/bash\nINPUT=$(cat)\nREASON=$(echo \"$INPUT\" | jq -r '.stop_reason // \"unknown\"' 2>/dev/null)\nHOOK_EVENT=$(echo \"$INPUT\" | jq -r '.hook_event_name // \"\"' 2>/dev/null)\nif [[ \"$REASON\" == \"user\" || \"$REASON\" == \"normal\" || -z \"$REASON\" ]]; then\n exit 0\nfi\nLOG=\"${CC_ERROR_ALERT_LOG:-$HOME/.claude/session-errors.log}\"\nMISSION=\"${CC_CONTEXT_MISSION_FILE:-$HOME/mission.md}\"\nTS=$(date -Iseconds)\nmkdir -p \"$(dirname \"$LOG\")\" 2>/dev/null\necho \"[$TS] Session stopped: reason=$REASON event=$HOOK_EVENT\" >> \"$LOG\"\nif [ -z \"$WSL_DISTRO_NAME\" ]; then\n notify-send \"Claude Code\" \"Session stopped: $REASON\" 2>/dev/null || true\n osascript -e \"display notification \\\"Session stopped: $REASON\\\" with title \\\"Claude Code\\\"\" 2>/dev/null || true\nelse\n powershell.exe -Command \"Write-Host 'Claude Code: Session stopped - $REASON'\" 2>/dev/null || true\nfi\nexit 0\n"}
1
+ {
2
+ "destructive-guard": "#!/bin/bash\n# ================================================================\n# destructive-guard.sh — Destructive Command Blocker\n# ================================================================\n# PURPOSE:\n# Blocks dangerous shell commands that can cause irreversible damage.\n# Catches rm -rf on sensitive paths, git reset --hard, git clean -fd,\n# and other destructive operations before they execute.\n#\n# Built after a real incident where rm -rf on a pnpm project\n# followed NTFS junctions and deleted an entire C:\\Users directory.\n# (GitHub Issue #36339)\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# WHAT IT BLOCKS (exit 2):\n# - rm -rf / rm -r on root, home, or parent paths (/, ~, .., /home, /etc)\n# - git reset --hard\n# - git clean -fd / git clean -fdx\n# - chmod -R 777 on sensitive paths\n# - find ... -delete on broad patterns\n#\n# WHAT IT ALLOWS (exit 0):\n# - rm -rf on specific project subdirectories (node_modules, dist, build)\n# - git reset --soft, git reset HEAD\n# - All non-destructive commands\n#\n# CONFIGURATION:\n# CC_ALLOW_DESTRUCTIVE=1 — disable this guard (not recommended)\n# CC_SAFE_DELETE_DIRS — colon-separated list of safe-to-delete dirs\n# default: \"node_modules:dist:build:.cache:__pycache__:coverage\"\n#\n# NOTE: On Windows/WSL2, rm -rf can follow NTFS junctions (symlinks)\n# and delete far more than intended. This guard is especially critical\n# on WSL2 environments.\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# Allow override (not recommended)\nif [[ \"${CC_ALLOW_DESTRUCTIVE:-0}\" == \"1\" ]]; then\n exit 0\nfi\n\n# Log function — records blocked commands for audit\nlog_block() {\n local reason=\"$1\"\n local logfile=\"${CC_BLOCK_LOG:-$HOME/.claude/blocked-commands.log}\"\n mkdir -p \"$(dirname \"$logfile\")\" 2>/dev/null\n echo \"[$(date -Iseconds)] BLOCKED: $reason | cmd: $COMMAND\" >> \"$logfile\" 2>/dev/null\n}\n\n# Safe directories that can be deleted\nSAFE_DIRS=\"${CC_SAFE_DELETE_DIRS:-node_modules:dist:build:.cache:__pycache__:coverage:.next:.nuxt:tmp}\"\n\n# --- Check 0: --no-preserve-root ---\nif echo \"$COMMAND\" | grep -qE \"rm\\\\s.*\\\\-\\\\-no-preserve-root\"; then\n echo \"BLOCKED: --no-preserve-root detected.\" >&2\n exit 2\nfi\n\n# --- Check 1: rm -rf on dangerous paths ---\nif echo \"$COMMAND\" | grep -qE 'rm\\s+(-[rf]+\\s+)*(\\/$|\\/\\s|\\/[^a-z]|\\/home|\\/etc|\\/usr|\\/var|\\/mnt|~\\/|~\\s*$|\\.\\.\\/|\\.\\.\\s*$|\\.\\s*$|\\.\\/\\s*$)'; then\n # Exception: safe directories\n SAFE=0\n IFS=':' read -ra DIRS <<< \"$SAFE_DIRS\"\n for dir in \"${DIRS[@]}\"; do\n if echo \"$COMMAND\" | grep -qE \"rm\\s+.*${dir}\\s*$|rm\\s+.*${dir}/\"; then\n SAFE=1\n break\n fi\n done\n\n # Check for mounted filesystems inside the target (NFS, Docker, bind mounts)\n # Why: GitHub #36640 — rm -rf on a dir with NFS mount deleted production data\n if (( SAFE == 0 )); then\n # Extract the target path from the rm command\n TARGET_PATH=$(echo \"$COMMAND\" | grep -oP 'rm\\s+(-[rf]+\\s+)*\\K\\S+')\n if [ -n \"$TARGET_PATH\" ] && command -v findmnt &>/dev/null; then\n if findmnt -n -o TARGET --submounts \"$TARGET_PATH\" 2>/dev/null | grep -q .; then\n log_block \"rm on path with mounted filesystem\"\n echo \"BLOCKED: Target contains a mounted filesystem (NFS, Docker, bind).\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Unmount the filesystem first, then retry.\" >&2\n exit 2\n fi\n fi\n fi\n\n if (( SAFE == 0 )); then\n log_block \"rm on sensitive path\"\n echo \"BLOCKED: rm on sensitive path detected.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"This command targets a sensitive directory that could cause\" >&2\n echo \"irreversible data loss. On WSL2, rm -rf can follow NTFS\" >&2\n echo \"junctions and delete far beyond the target directory.\" >&2\n echo \"\" >&2\n echo \"If you need to delete a specific subdirectory, target it directly:\" >&2\n echo \" rm -rf ./specific-folder\" >&2\n exit 2\n fi\nfi\n\n# --- Check 2: git reset --hard ---\n# Only match when git is the actual command, not inside strings/arguments\nif echo \"$COMMAND\" | grep -qE '^\\s*git\\s+reset\\s+--hard|;\\s*git\\s+reset\\s+--hard|&&\\s*git\\s+reset\\s+--hard|\\|\\|\\s*git\\s+reset\\s+--hard'; then\n log_block \"git reset --hard\"\n echo \"BLOCKED: git reset --hard discards all uncommitted changes.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Consider: git stash, or git reset --soft to keep changes staged.\" >&2\n exit 2\nfi\n\n# --- Check 3: git clean -fd ---\nif echo \"$COMMAND\" | grep -qE '^\\s*git\\s+clean\\s+-[a-z]*[fd]|;\\s*git\\s+clean|&&\\s*git\\s+clean|\\|\\|\\s*git\\s+clean'; then\n log_block \"git clean\"\n echo \"BLOCKED: git clean removes untracked files permanently.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Consider: git clean -n (dry run) first to see what would be deleted.\" >&2\n exit 2\nfi\n\n# --- Check 4: chmod 777 on broad paths ---\nif echo \"$COMMAND\" | grep -qE 'chmod\\s+(-R\\s+)?777\\s+(\\/|~|\\.)'; then\n echo \"BLOCKED: chmod 777 on broad path is a security risk.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n exit 2\nfi\n\n# --- Check 5: find -delete on broad patterns ---\nif echo \"$COMMAND\" | grep -qE 'find\\s+(\\/|~|\\.\\.)\\s.*-delete'; then\n echo \"BLOCKED: find -delete on broad path risks mass deletion.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Consider: find ... -print first to verify what matches.\" >&2\n exit 2\nfi\n\n# --- Check 6: sudo with dangerous commands ---\nif echo \"$COMMAND\" | grep -qE '^\\s*sudo\\s+(rm\\s+-[rf]|chmod\\s+(-R\\s+)?777|dd\\s+if=|mkfs)'; then\n log_block \"sudo with dangerous command\"\n echo \"BLOCKED: sudo with dangerous command detected.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Running destructive commands with sudo amplifies the damage.\" >&2\n echo \"Review the command carefully before proceeding.\" >&2\n exit 2\nfi\n\n\n# --- Check 7: PowerShell Remove-Item (Windows/WSL2) ---\n# Real incident: GitHub #37331 — destroyed entire repo\n# Skip if command is git commit (message text triggers false positive)\nif echo \"$COMMAND\" | grep -qE '^\\s*(git\\s+commit|echo\\s|printf\\s|cat\\s)'; then\n : # string output commands mentioning PS commands are not destructive\nelif echo \"$COMMAND\" | grep -qiE 'Remove-Item.*-Recurse.*-Force|Remove-Item.*-Force.*-Recurse|del\\s+/s\\s+/q|rd\\s+/s\\s+/q|rmdir\\s+/s\\s+/q'; then\n log_block \"PowerShell destructive command\"\n echo \"BLOCKED: Destructive PowerShell command detected.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Remove-Item with recursive force-delete can destroy entire directories\" >&2\n echo \"irreversibly. Target specific files instead.\" >&2\n exit 2\nfi\nif echo \"$COMMAND\" | grep -qE '(^|;|&&|\\|\\|)\\s*git\\s+(checkout|switch)\\s+.*(--force\\b|-f\\b|--discard-changes\\b)'; then\n log_block \"git checkout/switch --force\"\n echo \"BLOCKED: git checkout/switch with --force discards uncommitted changes.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Consider: git stash before switching, or use git switch without --force.\" >&2\n exit 2\nfi\n\nif echo \"$COMMAND\" | grep -qE '(sh|bash|zsh)\\s+-c\\s+'; then\n INNER=$(echo \"$COMMAND\" | sed -E \"s/.*(sh|bash|zsh)\\s+-c\\s+['\\\"]//\" | sed \"s/['\\\"]*$//\" )\n if echo \"$INNER\" | grep -qE 'rm\\s+-[rf]*\\s+[/~]|git\\s+reset\\s+--hard|git\\s+clean\\s+-[fd]+|mkfs\\.|dd\\s+if='; then\n echo \"BLOCKED: Destructive command hidden in shell wrapper\" >&2\n echo \"\" >&2\n echo \"Detected: $INNER\" >&2\n exit 2\n fi\nfi\nif echo \"$COMMAND\" | grep -qE '\\|\\s*(sh|bash)\\s*$'; then\n if echo \"$COMMAND\" | grep -qE 'rm\\s+-[rf]*\\s+[/~]|git\\s+reset\\s+--hard|git\\s+clean\\s+-[fd]+'; then\n echo \"BLOCKED: Destructive command piped to shell\" >&2\n exit 2\n fi\nfi\nexit 0",
3
+ "branch-guard": "#!/bin/bash\n# ================================================================\n# branch-guard.sh — Branch Push Protector\n# ================================================================\n# PURPOSE:\n# Prevents accidental git push to main/master branches AND\n# blocks force-push on ALL branches without explicit approval.\n#\n# Force-pushes rewrite history and can destroy teammates' work.\n# Protected branch pushes bypass code review.\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# WHAT IT BLOCKS (exit 2):\n# - git push origin main/master (any protected branch)\n# - git push --force (any branch — history rewriting)\n# - git push -f (short flag variant)\n# - git push --force-with-lease (still destructive)\n#\n# WHAT IT ALLOWS (exit 0):\n# - git push origin feature-branch (non-force)\n# - git push -u origin feature-branch\n# - All other git commands\n# - All non-git commands\n#\n# CONFIGURATION:\n# CC_PROTECT_BRANCHES — colon-separated list of protected branches\n# default: \"main:master\"\n# CC_ALLOW_FORCE_PUSH=1 — disable force-push protection\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# Only check git push commands\nif ! echo \"$COMMAND\" | grep -qE '^\\s*git\\s+push'; then\n exit 0\nfi\n\n# --- Check 1: Force push on ANY branch ---\nif [[ \"${CC_ALLOW_FORCE_PUSH:-0}\" != \"1\" ]]; then\n if echo \"$COMMAND\" | grep -qE 'git\\s+push\\s+.*(-f\\b|--force\\b|--force-with-lease\\b)'; then\n echo \"BLOCKED: Force push detected.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Force push rewrites remote history and can destroy\" >&2\n echo \"other people's work. This is almost never what you want.\" >&2\n echo \"\" >&2\n echo \"If you truly need to force push, set CC_ALLOW_FORCE_PUSH=1\" >&2\n exit 2\n fi\nfi\n\n# --- Check 2: Push to protected branches ---\nPROTECTED=\"${CC_PROTECT_BRANCHES:-main:master}\"\n\nBLOCKED=0\nIFS=':' read -ra BRANCHES <<< \"$PROTECTED\"\nfor branch in \"${BRANCHES[@]}\"; do\n if echo \"$COMMAND\" | grep -qwE \"origin\\s+${branch}|${branch}\\s|${branch}$\"; then\n BLOCKED=1\n break\n fi\ndone\n\nif (( BLOCKED == 1 )); then\n echo \"BLOCKED: Attempted push to protected branch.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Protected branches: $PROTECTED\" >&2\n echo \"\" >&2\n echo \"Push to a feature branch first, then create a pull request.\" >&2\n exit 2\nfi\n\nexit 0\n",
4
+ "syntax-check": "#!/bin/bash\n# ================================================================\n# syntax-check.sh — Automatic Syntax Validation After Edits\n# ================================================================\n# PURPOSE:\n# Runs syntax checks immediately after Claude Code edits or\n# writes a file. Catches syntax errors before they propagate\n# into downstream failures.\n#\n# SUPPORTED LANGUAGES:\n# .py — python -m py_compile\n# .sh — bash -n\n# .bash — bash -n\n# .json — jq empty\n# .yaml — python3 yaml.safe_load (if PyYAML installed)\n# .yml — python3 yaml.safe_load (if PyYAML installed)\n# .js — node --check (if node installed)\n# .ts — npx tsc --noEmit (if tsc available) [EXPERIMENTAL]\n#\n# TRIGGER: PostToolUse\n# MATCHER: \"Edit|Write\"\n#\n# DESIGN PHILOSOPHY:\n# - Never blocks (always exit 0) — reports errors but doesn't\n# prevent the edit from completing\n# - Silent on success — only speaks up when something is wrong\n# - Fails open — if a checker isn't installed, silently skips\n#\n# BORN FROM:\n# Countless sessions where Claude Code introduced a syntax error,\n# continued working for 10+ tool calls, then hit a wall when\n# trying to run the broken file. Catching it immediately saves\n# context window and frustration.\n# ================================================================\n\nINPUT=$(cat)\nFILE_PATH=$(echo \"$INPUT\" | jq -r '.tool_input.file_path // empty' 2>/dev/null)\n\n# No file path = nothing to check\nif [[ -z \"$FILE_PATH\" || ! -f \"$FILE_PATH\" ]]; then\n exit 0\nfi\n\nEXT=\"${FILE_PATH##*.}\"\n\ncase \"$EXT\" in\n py)\n if python3 -m py_compile \"$FILE_PATH\" 2>&1; then\n : # silent on success\n else\n echo \"SYNTAX ERROR (Python): $FILE_PATH\" >&2\n fi\n ;;\n sh|bash)\n if bash -n \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (Shell): $FILE_PATH\" >&2\n fi\n ;;\n json)\n if command -v jq &>/dev/null; then\n if jq empty \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (JSON): $FILE_PATH\" >&2\n fi\n fi\n ;;\n yaml|yml)\n if python3 -c \"import yaml\" 2>/dev/null; then\n if python3 -c \"\nimport yaml, sys\nwith open(sys.argv[1]) as f:\n yaml.safe_load(f)\n\" \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (YAML): $FILE_PATH\" >&2\n fi\n fi\n ;;\n js)\n if command -v node &>/dev/null; then\n if node --check \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (JavaScript): $FILE_PATH\" >&2\n fi\n fi\n ;;\n ts)\n # EXPERIMENTAL: TypeScript check requires tsc in PATH\n if command -v npx &>/dev/null; then\n if npx tsc --noEmit \"$FILE_PATH\" 2>&1; then\n :\n else\n echo \"SYNTAX ERROR (TypeScript) [experimental]: $FILE_PATH\" >&2\n fi\n fi\n ;;\n *)\n # Unknown extension — skip silently\n ;;\nesac\n\nexit 0\n",
5
+ "context-monitor": "#!/bin/bash\n# ================================================================\n# context-monitor.sh — Context Window Remaining Capacity Monitor\n# ================================================================\n# PURPOSE:\n# Monitors how much context window remains during a Claude Code\n# session. Issues graduated warnings (CAUTION → WARNING → CRITICAL\n# → EMERGENCY) so you never get killed by context exhaustion.\n#\n# HOW IT WORKS:\n# 1. Reads Claude Code's debug log to extract actual token usage\n# 2. Falls back to tool-call-count estimation when debug logs\n# are unavailable\n# 3. Saves current % to /tmp/cc-context-pct (other scripts can\n# read this)\n# 4. At CRITICAL/EMERGENCY, writes an evacuation template to\n# your mission file so you can hand off state before /compact\n#\n# TRIGGER: PostToolUse (all tools)\n# MATCHER: \"\" (empty = every tool invocation)\n#\n# CONFIGURATION:\n# CC_CONTEXT_MISSION_FILE — path to your mission/state file\n# default: $HOME/mission.md\n#\n# THRESHOLDS (edit below to taste):\n# CAUTION = 40% — be mindful of consumption\n# WARNING = 25% — finish current task, save state\n# CRITICAL = 20% — run /compact immediately\n# EMERGENCY = 15% — stop everything, evacuate\n#\n# BORN FROM:\n# A session that hit 3% context remaining with no warning.\n# The agent died mid-task and all in-flight work was lost.\n# Never again.\n# ================================================================\n\nSTATE_FILE=\"/tmp/cc-context-state\"\nPCT_FILE=\"/tmp/cc-context-pct\"\nCOUNTER_FILE=\"/tmp/cc-context-monitor-count\"\nMISSION_FILE=\"${CC_CONTEXT_MISSION_FILE:-$HOME/mission.md}\"\n\n# Tool invocation counter (fallback estimator)\nCOUNT=$(cat \"$COUNTER_FILE\" 2>/dev/null || echo 0)\nCOUNT=$((COUNT + 1))\necho \"$COUNT\" > \"$COUNTER_FILE\"\n\n# Check every 3rd invocation to reduce overhead\n# (but always check in CRITICAL/EMERGENCY state)\nLAST_STATE=$(cat \"$STATE_FILE\" 2>/dev/null || echo \"normal\")\nif [ $((COUNT % 3)) -ne 0 ] && [ \"$LAST_STATE\" != \"critical\" ] && [ \"$LAST_STATE\" != \"emergency\" ]; then\n exit 0\nfi\n\n# --- Extract context % from Claude Code debug logs ---\nget_context_pct() {\n local debug_dir=\"$HOME/.claude/debug\"\n if [ ! -d \"$debug_dir\" ]; then\n echo \"\"\n return\n fi\n\n local latest\n latest=$(find \"$debug_dir\" -maxdepth 1 -name '*.txt' -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2)\n if [ -z \"$latest\" ]; then\n echo \"\"\n return\n fi\n\n # Parse the last autocompact entry for token counts\n local line\n line=$(grep 'autocompact:' \"$latest\" 2>/dev/null | tail -1)\n if [ -z \"$line\" ]; then\n echo \"\"\n return\n fi\n\n local tokens window\n tokens=$(echo \"$line\" | sed 's/.*tokens=\\([0-9]*\\).*/\\1/')\n window=$(echo \"$line\" | sed 's/.*effectiveWindow=\\([0-9]*\\).*/\\1/')\n\n if [ -n \"$tokens\" ] && [ -n \"$window\" ] && [ \"$window\" -gt 0 ] 2>/dev/null; then\n local pct\n pct=$(( (window - tokens) * 100 / window ))\n echo \"$pct\"\n else\n echo \"\"\n fi\n}\n\nCONTEXT_PCT=$(get_context_pct)\n\n# Fallback: estimate from tool call count when debug logs unavailable\n# Assumes ~180 tool calls fills ~100% of context (conservative)\nif [ -z \"$CONTEXT_PCT\" ]; then\n CONTEXT_PCT=$(( 100 - (COUNT * 100 / 180) ))\n if [ \"$CONTEXT_PCT\" -lt 0 ]; then CONTEXT_PCT=0; fi\n SOURCE=\"estimate\"\nelse\n SOURCE=\"debug\"\nfi\n\necho \"$CONTEXT_PCT\" > \"$PCT_FILE\"\n\nTIMESTAMP=$(date '+%Y-%m-%d %H:%M')\n\n# --- Evacuation template (with cooldown to prevent spam) ---\nEVAC_COOLDOWN_FILE=\"/tmp/cc-context-evac-last\"\nEVAC_COOLDOWN_SEC=1800 # 30 min cooldown between template generations\n\ngenerate_evacuation_template() {\n local level=\"$1\"\n\n # Cooldown check\n if [ -f \"$EVAC_COOLDOWN_FILE\" ]; then\n local last_ts now_ts diff\n last_ts=$(cat \"$EVAC_COOLDOWN_FILE\" 2>/dev/null || echo 0)\n now_ts=$(date +%s)\n diff=$((now_ts - last_ts))\n if [ \"$diff\" -lt \"$EVAC_COOLDOWN_SEC\" ]; then\n return\n fi\n fi\n\n # Don't add a new template if there's already an unfilled one\n if [ -f \"$MISSION_FILE\" ] && grep -q '\\[TODO\\]' \"$MISSION_FILE\" 2>/dev/null; then\n return\n fi\n\n date +%s > \"$EVAC_COOLDOWN_FILE\"\n\n # Create mission file directory if needed\n mkdir -p \"$(dirname \"$MISSION_FILE\")\"\n\n cat >> \"$MISSION_FILE\" << EVAC_EOF\n\n## Context Evacuation Template (${level} - ${TIMESTAMP})\n<!-- Auto-generated by context-monitor.sh. Fill in before /compact -->\n### Current Task\n- Task: [TODO]\n- Progress: [TODO]\n- Files being edited: [TODO]\n\n### Git State\n- Branch: [TODO]\n- Uncommitted changes: [TODO]\n\n### Next Action\n- Next command/action: [TODO]\nEVAC_EOF\n}\n\n# --- Graduated warnings ---\nif [ \"$CONTEXT_PCT\" -le 15 ]; then\n # EMERGENCY\n if [ \"$LAST_STATE\" != \"emergency\" ]; then\n echo \"emergency\" > \"$STATE_FILE\"\n generate_evacuation_template \"EMERGENCY\"\n fi\n echo \"\"\n echo \"EMERGENCY: Context remaining ${CONTEXT_PCT}% (${SOURCE})\"\n echo \"Run /compact IMMEDIATELY. Evacuation template written to ${MISSION_FILE}.\"\n echo \"1. Fill in the [TODO] fields in the template\"\n echo \"2. Run /compact\"\n echo \"3. If needed, restart and resume from mission file\"\n echo \"No further work allowed. Evacuate only.\"\n\nelif [ \"$CONTEXT_PCT\" -le 20 ]; then\n # CRITICAL\n if [ \"$LAST_STATE\" != \"critical\" ]; then\n echo \"critical\" > \"$STATE_FILE\"\n generate_evacuation_template \"CRITICAL\"\n fi\n echo \"\"\n echo \"CRITICAL: Context remaining ${CONTEXT_PCT}% (${SOURCE})\"\n echo \"Run /compact IMMEDIATELY. Evacuation template written to ${MISSION_FILE}.\"\n echo \"1. Save current task state to the template\"\n echo \"2. Run /compact\"\n\nelif [ \"$CONTEXT_PCT\" -le 25 ]; then\n # WARNING\n if [ \"$LAST_STATE\" != \"warning\" ]; then\n echo \"warning\" > \"$STATE_FILE\"\n echo \"\"\n echo \"WARNING: Context remaining ${CONTEXT_PCT}% (${SOURCE})\"\n echo \"Do not start new large tasks. Finish current work and save state.\"\n fi\n\nelif [ \"$CONTEXT_PCT\" -le 40 ]; then\n # CAUTION\n if [ \"$LAST_STATE\" != \"caution\" ]; then\n echo \"caution\" > \"$STATE_FILE\"\n echo \"\"\n echo \"CAUTION: Context remaining ${CONTEXT_PCT}% (${SOURCE})\"\n echo \"Be mindful of context consumption. Keep interactions concise.\"\n fi\nfi\n\nexit 0\n",
6
+ "comment-strip": "#!/bin/bash\n# ================================================================\n# comment-strip.sh — Strip bash comments that break permissions\n# ================================================================\n# PURPOSE:\n# Claude Code sometimes adds comments to bash commands like:\n# # Check the diff\n# git diff HEAD~1\n# This breaks permission allowlists (e.g. Bash(git:*)) because\n# the matcher sees \"# Check the diff\" instead of \"git diff\".\n#\n# This hook strips leading comment lines and returns the clean\n# command via updatedInput, so permissions match correctly.\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# INCIDENT: GitHub Issue #29582 (18 reactions)\n# Users on linux/vscode report that bash comments added by Claude\n# cause permission prompts even when the command is allowlisted.\n#\n# HOW IT WORKS:\n# - Reads the command from tool_input\n# - Strips leading lines that start with #\n# - Strips trailing comments (everything after # on command lines)\n# - Returns updatedInput with the cleaned command\n# - Uses hookSpecificOutput.permissionDecision = \"allow\" only if\n# the command was modified (so it doesn't override other hooks)\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# Strip leading comment lines and empty lines\nCLEAN=$(echo \"$COMMAND\" | sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d')\n\n# If nothing changed, pass through\nif [[ \"$CLEAN\" == \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# If command is empty after stripping, don't modify\nif [[ -z \"$CLEAN\" ]]; then\n exit 0\nfi\n\n# Return cleaned command via hookSpecificOutput\n# permissionDecision is not set — let the normal permission flow handle it\n# We only modify the input so the permission matcher sees the real command\njq -n --arg cmd \"$CLEAN\" '{\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\",\n updatedInput: {\n command: $cmd\n }\n }\n}'\n",
7
+ "cd-git-allow": "#!/bin/bash\n# ================================================================\n# cd-git-allow.sh — Auto-approve cd+git compound commands\n# ================================================================\n# PURPOSE:\n# Claude Code shows \"Compound commands with cd and git require\n# approval\" for commands like: cd /path && git log\n# This is safe in trusted project directories but causes\n# constant permission prompts.\n#\n# This hook auto-approves cd+git compounds when the git operation\n# is read-only (log, diff, status, branch, show, etc.)\n# Destructive git operations (push, reset, clean) are NOT\n# auto-approved — they still require manual approval.\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# INCIDENT: GitHub Issue #32985 (9 reactions)\n#\n# WHAT IT AUTO-APPROVES:\n# - cd /path && git log\n# - cd /path && git diff\n# - cd /path && git status\n# - cd /path && git branch\n# - cd /path && git show\n# - cd /path && git rev-parse\n#\n# WHAT IT DOES NOT APPROVE (still prompts):\n# - cd /path && git push\n# - cd /path && git reset --hard\n# - cd /path && git clean\n# - cd /path && git checkout (could discard changes)\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# Only handle cd + git compounds\nif ! echo \"$COMMAND\" | grep -qE '^\\s*cd\\s+.*&&\\s*git\\s'; then\n exit 0\nfi\n\n# Extract the git subcommand\nGIT_CMD=$(echo \"$COMMAND\" | grep -oP '&&\\s*git\\s+\\K\\S+')\n\n# Read-only git operations — safe to auto-approve\nSAFE_GIT=\"log diff status branch show rev-parse tag remote stash-list describe name-rev\"\n\nfor safe in $SAFE_GIT; do\n if [[ \"$GIT_CMD\" == \"$safe\" ]]; then\n jq -n '{\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\",\n permissionDecision: \"allow\",\n permissionDecisionReason: \"cd+git compound auto-approved (read-only git operation)\"\n }\n }'\n exit 0\n fi\ndone\n\n# Not a read-only git op — let normal permission flow handle it\nexit 0\n",
8
+ "secret-guard": "#!/bin/bash\n# ================================================================\n# secret-guard.sh — Secret/Credential Leak Prevention\n# ================================================================\n# PURPOSE:\n# Prevents accidental exposure of secrets, API keys, and\n# credentials through git commits or shell output.\n#\n# Catches the most common ways secrets leak:\n# - git add .env (committing env files)\n# - git add credentials.json / *.pem / *.key\n# - echo $API_KEY or printenv (exposing secrets in output)\n#\n# TRIGGER: PreToolUse\n# MATCHER: \"Bash\"\n#\n# WHAT IT BLOCKS (exit 2):\n# - git add .env / .env.local / .env.production\n# - git add *credentials* / *secret* / *.pem / *.key\n# - git add -A or git add . when .env exists (warns)\n#\n# WHAT IT ALLOWS (exit 0):\n# - git add specific safe files\n# - Reading .env for application use (not committing)\n# - All non-git-add commands\n#\n# CONFIGURATION:\n# CC_SECRET_PATTERNS — colon-separated additional patterns to block\n# default: \".env:.env.local:.env.production:credentials:secret:*.pem:*.key:*.p12\"\n# ================================================================\n\nINPUT=$(cat)\nCOMMAND=$(echo \"$INPUT\" | jq -r '.tool_input.command // empty' 2>/dev/null)\n\nif [[ -z \"$COMMAND\" ]]; then\n exit 0\nfi\n\n# --- Check 1: git add of secret files ---\nif echo \"$COMMAND\" | grep -qE '^\\s*git\\s+add'; then\n # Direct .env file staging\n if echo \"$COMMAND\" | grep -qiE 'git\\s+add\\s+.*\\.env(\\s|$|\\.|/)'; then\n echo \"BLOCKED: Attempted to stage .env file.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \".env files contain secrets and should never be committed.\" >&2\n echo \"Add .env to .gitignore instead.\" >&2\n exit 2\n fi\n\n # Credential/key files\n if echo \"$COMMAND\" | grep -qiE 'git\\s+add\\s+.*(credentials|\\.pem|\\.key|\\.p12|\\.pfx|id_rsa|id_ed25519)'; then\n echo \"BLOCKED: Attempted to stage credential/key file.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"Key and credential files should never be committed to git.\" >&2\n echo \"Add them to .gitignore instead.\" >&2\n exit 2\n fi\n\n # git add -A or git add . when .env exists — warn but check\n if echo \"$COMMAND\" | grep -qE 'git\\s+add\\s+(-A|--all|\\.)(\\s|$)'; then\n # Check if .env exists in the current or project directory\n if [ -f \".env\" ] || [ -f \".env.local\" ] || [ -f \".env.production\" ]; then\n echo \"BLOCKED: 'git add .' with .env file present.\" >&2\n echo \"\" >&2\n echo \"Command: $COMMAND\" >&2\n echo \"\" >&2\n echo \"An .env file exists in this directory. 'git add .' would stage it.\" >&2\n echo \"Add specific files instead: git add src/ lib/ package.json\" >&2\n echo \"Or add .env to .gitignore first.\" >&2\n exit 2\n fi\n fi\nfi\n\nexit 0\n",
9
+ "api-error-alert": "#!/bin/bash\nINPUT=$(cat)\nREASON=$(echo \"$INPUT\" | jq -r '.stop_reason // \"unknown\"' 2>/dev/null)\nHOOK_EVENT=$(echo \"$INPUT\" | jq -r '.hook_event_name // \"\"' 2>/dev/null)\nif [[ \"$REASON\" == \"user\" || \"$REASON\" == \"normal\" || -z \"$REASON\" ]]; then\n exit 0\nfi\nLOG=\"${CC_ERROR_ALERT_LOG:-$HOME/.claude/session-errors.log}\"\nMISSION=\"${CC_CONTEXT_MISSION_FILE:-$HOME/mission.md}\"\nTS=$(date -Iseconds)\nmkdir -p \"$(dirname \"$LOG\")\" 2>/dev/null\necho \"[$TS] Session stopped: reason=$REASON event=$HOOK_EVENT\" >> \"$LOG\"\nif [ -z \"$WSL_DISTRO_NAME\" ]; then\n notify-send \"Claude Code\" \"Session stopped: $REASON\" 2>/dev/null || true\n osascript -e \"display notification \\\"Session stopped: $REASON\\\" with title \\\"Claude Code\\\"\" 2>/dev/null || true\nelse\n powershell.exe -Command \"Write-Host 'Claude Code: Session stopped - $REASON'\" 2>/dev/null || true\nfi\nexit 0\n"
10
+ }