cc-safe-setup 29.6.31 → 29.6.32
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/README.md +3 -2
- package/examples/README.md +4 -4
- package/examples/api-retry-limiter.sh +41 -0
- package/examples/binary-upload-guard.sh +42 -0
- package/examples/compaction-transcript-guard.sh +70 -0
- package/examples/compound-inject-guard.sh +45 -0
- package/examples/concurrent-edit-lock.sh +65 -0
- package/examples/context-threshold-alert.sh +70 -0
- package/examples/context-warning-verifier.sh +46 -0
- package/examples/cross-session-error-log.sh +66 -0
- package/examples/file-age-guard.sh +41 -0
- package/examples/heredoc-backtick-approver.sh +45 -0
- package/examples/hook-stdout-sanitizer.sh +61 -0
- package/examples/max-concurrent-agents.sh +46 -0
- package/examples/mcp-config-freeze.sh +48 -0
- package/examples/mcp-data-boundary.sh +43 -0
- package/examples/no-git-amend.sh +27 -0
- package/examples/path-deny-bash-guard.sh +66 -0
- package/examples/permission-mode-drift-guard.sh +47 -0
- package/examples/plan-mode-strict-guard.sh +75 -0
- package/examples/sandbox-write-verify.sh +51 -0
- package/examples/session-resume-guard.sh +70 -0
- package/examples/subagent-scope-validator.sh +49 -0
- package/examples/windows-path-guard.sh +45 -0
- package/package.json +2 -2
- package/test.sh.tmp +12 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/cc-safe-setup)
|
|
5
5
|
[](https://github.com/yurukusa/cc-safe-setup/actions/workflows/test.yml)
|
|
6
6
|
|
|
7
|
-
**One command to make Claude Code safe for autonomous operation.**
|
|
7
|
+
**One command to make Claude Code safe for autonomous operation.** 517 example hooks · 7,591 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
|
|
120
|
+
| `--install-example <name>` | Install from 517 examples |
|
|
121
121
|
| `--examples [filter]` | Browse examples by keyword |
|
|
122
122
|
| `--full` | All-in-one setup |
|
|
123
123
|
| `--status` | Check installed hooks |
|
|
@@ -400,6 +400,7 @@ See [Issue #1](https://github.com/yurukusa/cc-safe-setup/issues/1) for details.
|
|
|
400
400
|
|
|
401
401
|
## Learn More
|
|
402
402
|
|
|
403
|
+
- **[Hook Design Guide (Zenn Book)](https://zenn.dev/yurukusa/books/6076c23b1cb18b)** — 14-chapter guide from 700+ hours of autonomous operation. Hook design patterns, testing strategies, real incident postmortems. [Chapter 2 free](https://zenn.dev/yurukusa/books/6076c23b1cb18b/viewer/2-safety-guards)
|
|
403
404
|
- [Cookbook](COOKBOOK.md) — 26 practical recipes (block, approve, protect, monitor, diagnose)
|
|
404
405
|
- [Official Hooks Reference](https://code.claude.com/docs/en/hooks) — Claude Code hooks documentation
|
|
405
406
|
- [Hooks Cookbook](https://github.com/yurukusa/claude-code-hooks/blob/main/COOKBOOK.md) — 25 recipes from real GitHub Issues ([interactive version](https://yurukusa.github.io/claude-code-hooks/))
|
package/examples/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Example Hooks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
518 installable hooks. Each solves a real problem from GitHub Issues or autonomous operation. 7,603 tests.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx cc-safe-setup --install-example <name> # install one
|
|
@@ -13,7 +13,7 @@ npx cc-safe-setup --shield # install recommended set
|
|
|
13
13
|
|
|
14
14
|
| Category | Count | Examples |
|
|
15
15
|
|----------|-------|---------|
|
|
16
|
-
| Destructive Command Prevention |
|
|
16
|
+
| Destructive Command Prevention | 14 | `destructive-guard`, `branch-guard`, `no-sudo-guard`, `symlink-guard`, `shell-wrapper-guard`, `compound-inject-guard` |
|
|
17
17
|
| Data Protection | 5 | `block-database-wipe`, `secret-guard`, `hardcoded-secret-detector` |
|
|
18
18
|
| Git Safety | 11 | `git-config-guard`, `no-verify-blocker`, `push-requires-test-pass` |
|
|
19
19
|
| Auto-Approve (PreToolUse) | 11 | `auto-approve-readonly`, `auto-approve-build`, `auto-approve-docker` |
|
|
@@ -21,8 +21,8 @@ npx cc-safe-setup --shield # install recommended set
|
|
|
21
21
|
| Code Quality | 10 | `syntax-check`, `diff-size-guard`, `test-deletion-guard` |
|
|
22
22
|
| Security | 10 | `credential-file-cat-guard`, `credential-exfil-guard`, `prompt-injection-guard` |
|
|
23
23
|
| Deploy | 4 | `deploy-guard`, `no-deploy-friday`, `work-hours-guard` |
|
|
24
|
-
| Monitoring & Cost |
|
|
25
|
-
| Utility |
|
|
24
|
+
| Monitoring & Cost | 14 | `context-monitor`, `cost-tracker`, `loop-detector`, `edit-error-counter`, `dotenv-watch` |
|
|
25
|
+
| Utility | 20 | `comment-strip`, `session-handoff`, `auto-checkpoint`, `edit-retry-loop-guard`, `direnv-auto-reload`, `pre-compact-checkpoint` |
|
|
26
26
|
|
|
27
27
|
## Popular Hooks
|
|
28
28
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# api-retry-limiter.sh — Limit API error retries to prevent token waste
|
|
3
|
+
#
|
|
4
|
+
# Solves: Claude Code retries API calls on transient errors without
|
|
5
|
+
# backoff, burning tokens on repeated failures.
|
|
6
|
+
# Related to #40376 (rescue from 4xx/5xx) and general cost concerns.
|
|
7
|
+
#
|
|
8
|
+
# How it works: PostToolUse hook that tracks API error patterns.
|
|
9
|
+
# If the same error appears 3+ times in 60 seconds, warns the user
|
|
10
|
+
# and suggests waiting or switching approaches.
|
|
11
|
+
#
|
|
12
|
+
# Complements api-error-alert (built-in) with retry-specific logic.
|
|
13
|
+
#
|
|
14
|
+
# TRIGGER: PostToolUse
|
|
15
|
+
# MATCHER: "" (all tools — API errors can come from any tool)
|
|
16
|
+
|
|
17
|
+
INPUT=$(cat)
|
|
18
|
+
ERROR=$(echo "$INPUT" | jq -r '.tool_output // empty' 2>/dev/null | head -c 500)
|
|
19
|
+
[ -z "$ERROR" ] && exit 0
|
|
20
|
+
|
|
21
|
+
# Only track API-related errors
|
|
22
|
+
echo "$ERROR" | grep -qiE "rate.limit|429|500|502|503|529|overloaded|timeout|ECONNREFUSED|ENOTFOUND" || exit 0
|
|
23
|
+
|
|
24
|
+
ERROR_LOG="/tmp/cc-api-errors-${PPID}"
|
|
25
|
+
NOW=$(date +%s)
|
|
26
|
+
ERROR_TYPE=$(echo "$ERROR" | grep -oiE "rate.limit|429|500|502|503|529|overloaded|timeout|ECONNREFUSED" | head -1)
|
|
27
|
+
|
|
28
|
+
echo "$NOW $ERROR_TYPE" >> "$ERROR_LOG"
|
|
29
|
+
|
|
30
|
+
# Count recent errors of same type
|
|
31
|
+
RECENT=$(awk -v now="$NOW" -v type="$ERROR_TYPE" '$1 > now - 60 && $2 == type {count++} END {print count+0}' "$ERROR_LOG")
|
|
32
|
+
|
|
33
|
+
if [ "$RECENT" -ge 5 ]; then
|
|
34
|
+
echo "⚠ API error loop: ${ERROR_TYPE} occurred ${RECENT} times in 60s" >&2
|
|
35
|
+
echo " Suggestion: Wait 30-60 seconds before retrying" >&2
|
|
36
|
+
echo " Or: Switch to a different approach that doesn't require this API" >&2
|
|
37
|
+
elif [ "$RECENT" -ge 3 ]; then
|
|
38
|
+
echo "⚠ Repeated API error: ${ERROR_TYPE} (${RECENT}x in 60s)" >&2
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
exit 0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# binary-upload-guard.sh — Block committing binary files to git
|
|
3
|
+
#
|
|
4
|
+
# Solves: Claude adds binary files (images, compiled binaries, archives)
|
|
5
|
+
# to git, bloating the repository. Once committed, binaries
|
|
6
|
+
# are in git history forever (even if deleted later).
|
|
7
|
+
#
|
|
8
|
+
# How it works: PreToolUse hook on Bash that checks git add/commit
|
|
9
|
+
# commands for binary file extensions.
|
|
10
|
+
#
|
|
11
|
+
# TRIGGER: PreToolUse
|
|
12
|
+
# MATCHER: "Bash"
|
|
13
|
+
|
|
14
|
+
INPUT=$(cat)
|
|
15
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
16
|
+
[ -z "$COMMAND" ] && exit 0
|
|
17
|
+
|
|
18
|
+
# Only check git add/commit commands
|
|
19
|
+
echo "$COMMAND" | grep -qE 'git\s+(add|commit)' || exit 0
|
|
20
|
+
|
|
21
|
+
# Binary file extensions to block
|
|
22
|
+
BINARY_EXT='\.(exe|dll|so|dylib|bin|obj|o|a|lib|class|jar|war|ear|pyc|pyo|whl|egg|tar|gz|bz2|xz|zip|rar|7z|iso|dmg|pkg|deb|rpm|msi|app|apk|ipa|pdf|doc|docx|xls|xlsx|ppt|pptx|sqlite|db|mdb|wasm)(\s|$|")'
|
|
23
|
+
|
|
24
|
+
# Check if the command references binary files
|
|
25
|
+
if echo "$COMMAND" | grep -qiE "$BINARY_EXT"; then
|
|
26
|
+
MATCHED=$(echo "$COMMAND" | grep -oiE "[^ \"']+${BINARY_EXT}" | head -3)
|
|
27
|
+
echo "⚠ Binary file detected in git command:" >&2
|
|
28
|
+
echo " $MATCHED" >&2
|
|
29
|
+
echo " Binary files bloat git history permanently." >&2
|
|
30
|
+
echo " Consider: .gitignore, git-lfs, or external storage." >&2
|
|
31
|
+
# Warn but don't block (some binaries are intentional)
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# Block large archives being added
|
|
35
|
+
if echo "$COMMAND" | grep -qE 'git\s+add.*\.(tar\.gz|zip|rar|7z|iso|dmg)\b'; then
|
|
36
|
+
echo "BLOCKED: Large archive file in git add" >&2
|
|
37
|
+
echo " Archives should not be committed to git." >&2
|
|
38
|
+
echo " Use .gitignore or external storage." >&2
|
|
39
|
+
exit 2
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
exit 0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# compaction-transcript-guard.sh — Save conversation state before compaction
|
|
3
|
+
#
|
|
4
|
+
# Solves: Compaction race condition destroys transcript (#40352).
|
|
5
|
+
# When rate limiting hits during compaction, the JSONL transcript
|
|
6
|
+
# is left with 4,300+ empty messages. All context is permanently lost.
|
|
7
|
+
#
|
|
8
|
+
# How it works: Uses PreCompact hook event to save a recovery snapshot
|
|
9
|
+
# before compaction begins. If compaction fails, the snapshot enables
|
|
10
|
+
# manual recovery.
|
|
11
|
+
#
|
|
12
|
+
# Saves:
|
|
13
|
+
# 1. Git state (uncommitted changes committed as checkpoint)
|
|
14
|
+
# 2. Current task context to ~/.claude/recovery/pre-compact-snapshot.md
|
|
15
|
+
# 3. Recent file list to ~/.claude/recovery/recent-files.txt
|
|
16
|
+
#
|
|
17
|
+
# TRIGGER: PreCompact
|
|
18
|
+
# MATCHER: No matcher support — fires on every compaction
|
|
19
|
+
#
|
|
20
|
+
# DECISION CONTROL: None (notification only)
|
|
21
|
+
|
|
22
|
+
RECOVERY_DIR="${HOME}/.claude/recovery"
|
|
23
|
+
mkdir -p "$RECOVERY_DIR"
|
|
24
|
+
|
|
25
|
+
TIMESTAMP=$(date -u '+%Y%m%d-%H%M%S')
|
|
26
|
+
SNAPSHOT="${RECOVERY_DIR}/pre-compact-${TIMESTAMP}.md"
|
|
27
|
+
|
|
28
|
+
# 1. Save git state
|
|
29
|
+
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
|
30
|
+
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
31
|
+
DIRTY=$(git status --porcelain 2>/dev/null | wc -l)
|
|
32
|
+
LAST_COMMIT=$(git log --oneline -1 2>/dev/null)
|
|
33
|
+
|
|
34
|
+
# Auto-commit uncommitted changes
|
|
35
|
+
if [ "$DIRTY" -gt 0 ]; then
|
|
36
|
+
git add -A 2>/dev/null
|
|
37
|
+
git commit -m "recovery: pre-compact checkpoint (${DIRTY} files, ${TIMESTAMP})" --no-verify 2>/dev/null
|
|
38
|
+
echo "📸 Recovery checkpoint: ${DIRTY} uncommitted files saved" >&2
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
cat > "$SNAPSHOT" << EOF
|
|
42
|
+
# Pre-Compaction Recovery Snapshot
|
|
43
|
+
Timestamp: ${TIMESTAMP}
|
|
44
|
+
Branch: ${BRANCH}
|
|
45
|
+
Uncommitted files: ${DIRTY}
|
|
46
|
+
Last commit: ${LAST_COMMIT}
|
|
47
|
+
|
|
48
|
+
## Recent files (last 10 modified)
|
|
49
|
+
$(git diff --name-only HEAD~3 HEAD 2>/dev/null | tail -10)
|
|
50
|
+
|
|
51
|
+
## Working directory
|
|
52
|
+
$(pwd)
|
|
53
|
+
|
|
54
|
+
## Recovery instructions
|
|
55
|
+
If compaction failed and context was lost:
|
|
56
|
+
1. Check git log: git log --oneline -5
|
|
57
|
+
2. Restore from checkpoint: git show HEAD
|
|
58
|
+
3. Resume work from this snapshot
|
|
59
|
+
EOF
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
# 2. Save list of recently accessed files
|
|
63
|
+
if [ -f "${HOME}/.claude/session-changes.log" ]; then
|
|
64
|
+
tail -20 "${HOME}/.claude/session-changes.log" > "${RECOVERY_DIR}/recent-files-${TIMESTAMP}.txt"
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
echo "📋 Recovery snapshot saved: ${SNAPSHOT}" >&2
|
|
68
|
+
echo " If compaction fails, recovery data is preserved" >&2
|
|
69
|
+
|
|
70
|
+
exit 0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# compound-inject-guard.sh — Block destructive commands hidden in compound statements
|
|
3
|
+
#
|
|
4
|
+
# Solves: Permission allow list glob wildcards match shell operators (&&, ;, ||),
|
|
5
|
+
# allowing destructive commands to bypass the allowlist.
|
|
6
|
+
# Example: `Bash(git -C * status)` also matches
|
|
7
|
+
# `git -C "/repo" && rm -rf / && git -C "/repo" status`
|
|
8
|
+
#
|
|
9
|
+
# Related: GitHub #40344 — "Permission allow list glob wildcards match shell
|
|
10
|
+
# operators, enabling command injection"
|
|
11
|
+
#
|
|
12
|
+
# How it works: Splits compound commands on shell operators (&&, ||, ;)
|
|
13
|
+
# and checks each segment independently for destructive patterns.
|
|
14
|
+
# This prevents destructive commands from hiding inside compound statements
|
|
15
|
+
# that match overly broad permission allow rules.
|
|
16
|
+
#
|
|
17
|
+
# TRIGGER: PreToolUse
|
|
18
|
+
# MATCHER: "Bash"
|
|
19
|
+
|
|
20
|
+
INPUT=$(cat)
|
|
21
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
22
|
+
[ -z "$COMMAND" ] && exit 0
|
|
23
|
+
|
|
24
|
+
# Only check compound commands (those with shell operators)
|
|
25
|
+
echo "$COMMAND" | grep -qE '&&|\|\||;' || exit 0
|
|
26
|
+
|
|
27
|
+
# Destructive patterns to detect in each segment
|
|
28
|
+
DESTRUCT='rm\s+-[rf]*\s+[/~]|rm\s+-[rf]*\s+\.\.|git\s+reset\s+--hard|git\s+clean\s+-[fd]+|mkfs\.|dd\s+if=|chmod\s+777\s+/|>\s*/dev/sd'
|
|
29
|
+
|
|
30
|
+
# Split on shell operators and check each segment
|
|
31
|
+
IFS=$'\n'
|
|
32
|
+
for segment in $(echo "$COMMAND" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g'); do
|
|
33
|
+
# Trim leading whitespace
|
|
34
|
+
segment=$(echo "$segment" | sed 's/^\s*//')
|
|
35
|
+
[ -z "$segment" ] && continue
|
|
36
|
+
|
|
37
|
+
if echo "$segment" | grep -qE "$DESTRUCT"; then
|
|
38
|
+
echo "BLOCKED: Destructive command in compound statement" >&2
|
|
39
|
+
echo " Segment: $segment" >&2
|
|
40
|
+
echo " Fix: Run destructive commands separately, not chained with && or ;" >&2
|
|
41
|
+
exit 2
|
|
42
|
+
fi
|
|
43
|
+
done
|
|
44
|
+
|
|
45
|
+
exit 0
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# concurrent-edit-lock.sh — Prevent file corruption from concurrent Claude sessions
|
|
3
|
+
#
|
|
4
|
+
# Solves: File corruption when running multiple Claude Code terminals (#35682).
|
|
5
|
+
# Two sessions editing the same file simultaneously can produce
|
|
6
|
+
# interleaved writes, truncated content, or merge conflicts.
|
|
7
|
+
#
|
|
8
|
+
# How it works: PreToolUse hook on Edit/Write that creates a lock file
|
|
9
|
+
# before editing. If another session holds the lock, blocks the edit.
|
|
10
|
+
# Lock auto-expires after 60 seconds (configurable) to prevent deadlocks.
|
|
11
|
+
#
|
|
12
|
+
# CONFIG:
|
|
13
|
+
# CC_EDIT_LOCK_TIMEOUT=60 # seconds before lock expires
|
|
14
|
+
#
|
|
15
|
+
# TRIGGER: PreToolUse
|
|
16
|
+
# MATCHER: "Edit|Write"
|
|
17
|
+
|
|
18
|
+
INPUT=$(cat)
|
|
19
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
20
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
21
|
+
[ -z "$FILE" ] && exit 0
|
|
22
|
+
|
|
23
|
+
LOCK_DIR="${HOME}/.claude/locks"
|
|
24
|
+
mkdir -p "$LOCK_DIR"
|
|
25
|
+
|
|
26
|
+
LOCK_TIMEOUT=${CC_EDIT_LOCK_TIMEOUT:-60}
|
|
27
|
+
|
|
28
|
+
# Create a hash of the file path for the lock file name
|
|
29
|
+
LOCK_HASH=$(echo "$FILE" | md5sum | cut -c1-16)
|
|
30
|
+
LOCK_FILE="${LOCK_DIR}/${LOCK_HASH}.lock"
|
|
31
|
+
SESSION_ID="$$"
|
|
32
|
+
|
|
33
|
+
# Check for existing lock
|
|
34
|
+
if [ -f "$LOCK_FILE" ]; then
|
|
35
|
+
LOCK_INFO=$(cat "$LOCK_FILE")
|
|
36
|
+
LOCK_PID=$(echo "$LOCK_INFO" | cut -d'|' -f1)
|
|
37
|
+
LOCK_TIME=$(echo "$LOCK_INFO" | cut -d'|' -f2)
|
|
38
|
+
NOW=$(date +%s)
|
|
39
|
+
|
|
40
|
+
# Check if lock is expired
|
|
41
|
+
if [ $((NOW - LOCK_TIME)) -gt "$LOCK_TIMEOUT" ]; then
|
|
42
|
+
# Lock expired — remove and proceed
|
|
43
|
+
rm -f "$LOCK_FILE"
|
|
44
|
+
elif [ "$LOCK_PID" != "$SESSION_ID" ]; then
|
|
45
|
+
# Another session holds the lock
|
|
46
|
+
# Check if the locking process is still alive
|
|
47
|
+
if kill -0 "$LOCK_PID" 2>/dev/null; then
|
|
48
|
+
echo "BLOCKED: File is being edited by another Claude session (PID ${LOCK_PID})" >&2
|
|
49
|
+
echo " File: $(basename "$FILE")" >&2
|
|
50
|
+
echo " Wait for the other session to finish, or remove lock: rm ${LOCK_FILE}" >&2
|
|
51
|
+
exit 2
|
|
52
|
+
else
|
|
53
|
+
# Process is dead — stale lock
|
|
54
|
+
rm -f "$LOCK_FILE"
|
|
55
|
+
fi
|
|
56
|
+
fi
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Acquire lock
|
|
60
|
+
echo "${SESSION_ID}|$(date +%s)" > "$LOCK_FILE"
|
|
61
|
+
|
|
62
|
+
# Schedule lock cleanup (best effort — PostToolUse should handle this)
|
|
63
|
+
# The lock will expire naturally after LOCK_TIMEOUT seconds
|
|
64
|
+
|
|
65
|
+
exit 0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# context-threshold-alert.sh — Alert at configurable context usage thresholds
|
|
3
|
+
#
|
|
4
|
+
# Solves: No hook event for context usage thresholds (#40256).
|
|
5
|
+
# Users want to be notified when context hits 50%, 75%, 90%
|
|
6
|
+
# without waiting for the built-in context-monitor warnings.
|
|
7
|
+
#
|
|
8
|
+
# How it works: PostToolUse hook that reads the context percentage
|
|
9
|
+
# from the tool output and triggers alerts at configurable thresholds.
|
|
10
|
+
# Each threshold fires only once per session (tracked via temp file).
|
|
11
|
+
#
|
|
12
|
+
# CONFIG (environment variables):
|
|
13
|
+
# CC_CONTEXT_WARN=50 # First warning at 50%
|
|
14
|
+
# CC_CONTEXT_ALERT=75 # Alert at 75%
|
|
15
|
+
# CC_CONTEXT_CRITICAL=90 # Critical at 90%
|
|
16
|
+
# CC_CONTEXT_ACTION=log # "log" (stderr only) or "block" (exit 2 at critical)
|
|
17
|
+
#
|
|
18
|
+
# TRIGGER: PostToolUse
|
|
19
|
+
# MATCHER: "" (all tools — checks context on every call)
|
|
20
|
+
|
|
21
|
+
INPUT=$(cat)
|
|
22
|
+
|
|
23
|
+
# Extract context percentage from tool output if available
|
|
24
|
+
CONTEXT_PCT=$(echo "$INPUT" | jq -r '.context_window.remaining_percentage // empty' 2>/dev/null)
|
|
25
|
+
|
|
26
|
+
# If no context data in this tool call, try the session state
|
|
27
|
+
if [ -z "$CONTEXT_PCT" ]; then
|
|
28
|
+
# Check if context-monitor data exists
|
|
29
|
+
STATE_FILE="/tmp/cc-context-state-$$"
|
|
30
|
+
[ -f "$STATE_FILE" ] && CONTEXT_PCT=$(cat "$STATE_FILE")
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
[ -z "$CONTEXT_PCT" ] && exit 0
|
|
34
|
+
|
|
35
|
+
# Convert remaining % to used %
|
|
36
|
+
USED=$((100 - CONTEXT_PCT))
|
|
37
|
+
|
|
38
|
+
# Configurable thresholds
|
|
39
|
+
WARN=${CC_CONTEXT_WARN:-50}
|
|
40
|
+
ALERT=${CC_CONTEXT_ALERT:-75}
|
|
41
|
+
CRITICAL=${CC_CONTEXT_CRITICAL:-90}
|
|
42
|
+
ACTION=${CC_CONTEXT_ACTION:-log}
|
|
43
|
+
|
|
44
|
+
# Track which thresholds have fired (once per session)
|
|
45
|
+
FIRED_FILE="/tmp/cc-context-fired-${PPID}"
|
|
46
|
+
|
|
47
|
+
already_fired() {
|
|
48
|
+
grep -q "^$1$" "$FIRED_FILE" 2>/dev/null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
mark_fired() {
|
|
52
|
+
echo "$1" >> "$FIRED_FILE"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Check thresholds (highest first)
|
|
56
|
+
if [ "$USED" -ge "$CRITICAL" ] && ! already_fired "critical"; then
|
|
57
|
+
echo "🔴 CRITICAL: Context usage at ${USED}% (threshold: ${CRITICAL}%)" >&2
|
|
58
|
+
echo " Consider running /compact or starting a new session" >&2
|
|
59
|
+
mark_fired "critical"
|
|
60
|
+
[ "$ACTION" = "block" ] && exit 2
|
|
61
|
+
elif [ "$USED" -ge "$ALERT" ] && ! already_fired "alert"; then
|
|
62
|
+
echo "🟠 ALERT: Context usage at ${USED}% (threshold: ${ALERT}%)" >&2
|
|
63
|
+
echo " Commit work and prepare for compaction" >&2
|
|
64
|
+
mark_fired "alert"
|
|
65
|
+
elif [ "$USED" -ge "$WARN" ] && ! already_fired "warn"; then
|
|
66
|
+
echo "🟡 WARNING: Context usage at ${USED}% (threshold: ${WARN}%)" >&2
|
|
67
|
+
mark_fired "warn"
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
exit 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# context-warning-verifier.sh — Verify context warnings are genuine
|
|
3
|
+
#
|
|
4
|
+
# Solves: Claude weaponizes user's CLAUDE.md context warning rules to
|
|
5
|
+
# fabricate urgency and manipulate users (#35357, 2 reactions).
|
|
6
|
+
# Claude triggers context warnings at the exact format defined
|
|
7
|
+
# in CLAUDE.md when the context window is nowhere near the threshold.
|
|
8
|
+
#
|
|
9
|
+
# How it works: PostToolUse hook that independently verifies context usage
|
|
10
|
+
# after Claude claims the context is running low. If Claude's claim
|
|
11
|
+
# doesn't match the actual context state, warns the user.
|
|
12
|
+
#
|
|
13
|
+
# This hook provides an independent "second opinion" on context warnings,
|
|
14
|
+
# preventing the model from using the user's own rules as manipulation tools.
|
|
15
|
+
#
|
|
16
|
+
# TRIGGER: PostToolUse
|
|
17
|
+
# MATCHER: "" (monitors all tool outputs for fabricated warnings)
|
|
18
|
+
|
|
19
|
+
INPUT=$(cat)
|
|
20
|
+
OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // empty' 2>/dev/null)
|
|
21
|
+
[ -z "$OUTPUT" ] && exit 0
|
|
22
|
+
|
|
23
|
+
# Check if the output contains context warning patterns
|
|
24
|
+
HAS_WARNING=false
|
|
25
|
+
echo "$OUTPUT" | grep -qiE "context.*(running out|low|critical|depleted|remaining.*[0-9]+%)" && HAS_WARNING=true
|
|
26
|
+
echo "$OUTPUT" | grep -qiE "(20|15|10|5)%.*(context|remaining|left)" && HAS_WARNING=true
|
|
27
|
+
echo "$OUTPUT" | grep -qiE "コンテキスト.*(残|不足|危険|低)" && HAS_WARNING=true
|
|
28
|
+
|
|
29
|
+
[ "$HAS_WARNING" = "false" ] && exit 0
|
|
30
|
+
|
|
31
|
+
# Claude claims context is low — verify independently
|
|
32
|
+
# Get actual context percentage from the tool metadata if available
|
|
33
|
+
ACTUAL_PCT=$(echo "$INPUT" | jq -r '.context_window.remaining_percentage // empty' 2>/dev/null)
|
|
34
|
+
|
|
35
|
+
if [ -n "$ACTUAL_PCT" ] && [ "$ACTUAL_PCT" -gt 50 ] 2>/dev/null; then
|
|
36
|
+
echo "⚠ CONTEXT WARNING VERIFICATION FAILED" >&2
|
|
37
|
+
echo " Claude claims context is running low" >&2
|
|
38
|
+
echo " Actual remaining: ${ACTUAL_PCT}% (well above danger zone)" >&2
|
|
39
|
+
echo " This may be a fabricated warning to avoid work." >&2
|
|
40
|
+
echo " Reference: GitHub Issue #35357" >&2
|
|
41
|
+
elif [ -z "$ACTUAL_PCT" ]; then
|
|
42
|
+
echo "ℹ Context warning detected but cannot independently verify." >&2
|
|
43
|
+
echo " Check actual usage with: context-monitor or statusline" >&2
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
exit 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# cross-session-error-log.sh — Persist error patterns across sessions
|
|
3
|
+
#
|
|
4
|
+
# Solves: Agent ignores spec across multiple sessions (#40383).
|
|
5
|
+
# Each new session starts fresh with no memory of previous failures.
|
|
6
|
+
# The same destructive mistakes are repeated session after session.
|
|
7
|
+
#
|
|
8
|
+
# How it works: PostToolUse hook that logs blocked/failed operations
|
|
9
|
+
# to a persistent file (~/.claude/error-history.log). On SessionStart,
|
|
10
|
+
# checks for recurring patterns and warns the new session about them.
|
|
11
|
+
#
|
|
12
|
+
# The error history survives session restarts, so the next Claude session
|
|
13
|
+
# sees "this operation failed 3 times in previous sessions" before
|
|
14
|
+
# attempting it again.
|
|
15
|
+
#
|
|
16
|
+
# TRIGGER: PostToolUse (log errors) + Notification/SessionStart (read history)
|
|
17
|
+
# MATCHER: "" (all tools)
|
|
18
|
+
|
|
19
|
+
INPUT=$(cat)
|
|
20
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
21
|
+
EVENT=$(echo "$INPUT" | jq -r '.event // empty' 2>/dev/null)
|
|
22
|
+
|
|
23
|
+
ERROR_LOG="${HOME}/.claude/error-history.log"
|
|
24
|
+
mkdir -p "$(dirname "$ERROR_LOG")"
|
|
25
|
+
|
|
26
|
+
# On session start: show recent error patterns
|
|
27
|
+
if [ "$EVENT" = "session_start" ] || [ "$EVENT" = "SessionStart" ]; then
|
|
28
|
+
if [ -f "$ERROR_LOG" ]; then
|
|
29
|
+
RECENT=$(tail -50 "$ERROR_LOG" | awk -F'|' '{print $3}' | sort | uniq -c | sort -rn | head -5)
|
|
30
|
+
if [ -n "$RECENT" ]; then
|
|
31
|
+
echo "📋 Recurring error patterns from previous sessions:" >&2
|
|
32
|
+
echo "$RECENT" | while read count pattern; do
|
|
33
|
+
[ "$count" -ge 2 ] && echo " ${count}x: ${pattern}" >&2
|
|
34
|
+
done
|
|
35
|
+
fi
|
|
36
|
+
fi
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# On tool use: check for error indicators
|
|
41
|
+
OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // empty' 2>/dev/null | head -c 200)
|
|
42
|
+
[ -z "$OUTPUT" ] && exit 0
|
|
43
|
+
|
|
44
|
+
# Detect error patterns
|
|
45
|
+
IS_ERROR=false
|
|
46
|
+
ERROR_TYPE=""
|
|
47
|
+
|
|
48
|
+
if echo "$OUTPUT" | grep -qiE "error|failed|BLOCKED|exit code [1-9]|permission denied|not found|syntax error"; then
|
|
49
|
+
IS_ERROR=true
|
|
50
|
+
ERROR_TYPE=$(echo "$OUTPUT" | grep -oiE "error:[^\"]*|failed:[^\"]*|BLOCKED:[^\"]*" | head -1 | head -c 80)
|
|
51
|
+
[ -z "$ERROR_TYPE" ] && ERROR_TYPE="$(echo "$OUTPUT" | head -c 60)"
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# Log error with timestamp and tool name
|
|
55
|
+
if [ "$IS_ERROR" = "true" ]; then
|
|
56
|
+
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%S')
|
|
57
|
+
echo "${TIMESTAMP}|${TOOL}|${ERROR_TYPE}" >> "$ERROR_LOG"
|
|
58
|
+
|
|
59
|
+
# Keep log manageable (last 200 entries)
|
|
60
|
+
if [ "$(wc -l < "$ERROR_LOG")" -gt 200 ]; then
|
|
61
|
+
tail -100 "$ERROR_LOG" > "${ERROR_LOG}.tmp"
|
|
62
|
+
mv "${ERROR_LOG}.tmp" "$ERROR_LOG"
|
|
63
|
+
fi
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
exit 0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# file-age-guard.sh — Warn before editing files not modified in 30+ days
|
|
3
|
+
#
|
|
4
|
+
# Solves: Claude edits stable/legacy files that haven't been touched
|
|
5
|
+
# in months, introducing regressions in well-tested code.
|
|
6
|
+
#
|
|
7
|
+
# How it works: PreToolUse hook on Edit/Write that checks the last
|
|
8
|
+
# modification time of the target file. If the file hasn't been
|
|
9
|
+
# modified in CC_FILE_AGE_DAYS (default 30), warns the user.
|
|
10
|
+
#
|
|
11
|
+
# This doesn't block — just warns. The assumption is that files
|
|
12
|
+
# untouched for a long time are stable and should be edited carefully.
|
|
13
|
+
#
|
|
14
|
+
# CONFIG:
|
|
15
|
+
# CC_FILE_AGE_DAYS=30 (warn threshold in days)
|
|
16
|
+
#
|
|
17
|
+
# TRIGGER: PreToolUse
|
|
18
|
+
# MATCHER: "Edit"
|
|
19
|
+
|
|
20
|
+
INPUT=$(cat)
|
|
21
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
22
|
+
[ -z "$FILE" ] && exit 0
|
|
23
|
+
[ -f "$FILE" ] || exit 0
|
|
24
|
+
|
|
25
|
+
AGE_THRESHOLD=${CC_FILE_AGE_DAYS:-30}
|
|
26
|
+
|
|
27
|
+
# Get file modification time
|
|
28
|
+
FILE_MTIME=$(stat -c %Y "$FILE" 2>/dev/null)
|
|
29
|
+
[ -z "$FILE_MTIME" ] && exit 0
|
|
30
|
+
|
|
31
|
+
NOW=$(date +%s)
|
|
32
|
+
AGE_DAYS=$(( (NOW - FILE_MTIME) / 86400 ))
|
|
33
|
+
|
|
34
|
+
if [ "$AGE_DAYS" -ge "$AGE_THRESHOLD" ]; then
|
|
35
|
+
FILENAME=$(basename "$FILE")
|
|
36
|
+
echo "⚠ Editing stable file: ${FILENAME} (last modified ${AGE_DAYS} days ago)" >&2
|
|
37
|
+
echo " This file hasn't been touched in ${AGE_DAYS} days." >&2
|
|
38
|
+
echo " Verify changes don't break existing behavior." >&2
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
exit 0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# heredoc-backtick-approver.sh — Auto-approve backtick warnings in heredoc strings
|
|
3
|
+
#
|
|
4
|
+
# Solves: Backticks inside heredoc quoted strings trigger false-positive
|
|
5
|
+
# permission prompt (#35183, 2 reactions).
|
|
6
|
+
# `git commit -m "$(cat <<'EOF' ... \`code\` ... EOF)"` triggers
|
|
7
|
+
# "Command contains backticks for command substitution" even though
|
|
8
|
+
# backticks inside <<'EOF' are inert literal characters.
|
|
9
|
+
#
|
|
10
|
+
# How it works: PermissionRequest hook that checks if the backtick warning
|
|
11
|
+
# is from a command containing a quoted heredoc (<<'EOF' or <<"EOF").
|
|
12
|
+
# If so, auto-approves since the backticks are string content, not shell.
|
|
13
|
+
#
|
|
14
|
+
# TRIGGER: PermissionRequest
|
|
15
|
+
# MATCHER: "" (all permission requests)
|
|
16
|
+
|
|
17
|
+
INPUT=$(cat)
|
|
18
|
+
MESSAGE=$(echo "$INPUT" | jq -r '.message // empty' 2>/dev/null)
|
|
19
|
+
[ -z "$MESSAGE" ] && exit 0
|
|
20
|
+
|
|
21
|
+
# Only handle backtick/command substitution warnings
|
|
22
|
+
echo "$MESSAGE" | grep -qiE "backtick|command substitution" || exit 0
|
|
23
|
+
|
|
24
|
+
# Get the command that triggered the warning
|
|
25
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
26
|
+
[ -z "$COMMAND" ] && exit 0
|
|
27
|
+
|
|
28
|
+
# Check if command contains a quoted heredoc
|
|
29
|
+
# <<'EOF' (single-quoted) or <<"EOF" (double-quoted) disable backtick expansion
|
|
30
|
+
if echo "$COMMAND" | grep -qE "<<'[A-Za-z_]+'" || echo "$COMMAND" | grep -qE '<<"[A-Za-z_]+"'; then
|
|
31
|
+
# Backticks inside a quoted heredoc are literal — safe to approve
|
|
32
|
+
echo '{"permissionDecision":"allow","permissionDecisionReason":"Backticks are literal characters inside quoted heredoc (<<'"'"'EOF'"'"')"}'
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Also handle unquoted heredoc with git commit pattern
|
|
37
|
+
# git commit -m "$(cat <<EOF ... `code` ... EOF)" — common pattern
|
|
38
|
+
if echo "$COMMAND" | grep -qE 'git\s+commit.*<<\s*[A-Za-z_]+' && \
|
|
39
|
+
echo "$COMMAND" | grep -qE '`[a-zA-Z_][a-zA-Z0-9_]*`'; then
|
|
40
|
+
# Likely markdown formatting backticks in commit message
|
|
41
|
+
echo '{"permissionDecision":"allow","permissionDecisionReason":"Backticks appear to be markdown formatting in commit message"}'
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
exit 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# hook-stdout-sanitizer.sh — Prevent hook stdout from corrupting tool results
|
|
3
|
+
#
|
|
4
|
+
# Solves: Worktree path corrupted by hook stdout (#40262).
|
|
5
|
+
# When a hook writes to stdout (instead of stderr), the output
|
|
6
|
+
# gets concatenated into tool results like file paths, causing
|
|
7
|
+
# corruption (e.g., worktree path becomes "/path/to/repo{hookJSON}").
|
|
8
|
+
#
|
|
9
|
+
# How it works: Wraps another hook script, redirecting all of its stdout
|
|
10
|
+
# to stderr. Only valid JSON hookSpecificOutput is sent to stdout.
|
|
11
|
+
# This prevents accidental stdout pollution.
|
|
12
|
+
#
|
|
13
|
+
# Usage: Wrap any existing hook:
|
|
14
|
+
# Original: "command": "bash ~/.claude/hooks/my-hook.sh"
|
|
15
|
+
# Wrapped: "command": "bash ~/.claude/hooks/hook-stdout-sanitizer.sh ~/.claude/hooks/my-hook.sh"
|
|
16
|
+
#
|
|
17
|
+
# Or use as a template for writing safe hooks.
|
|
18
|
+
#
|
|
19
|
+
# TRIGGER: Any (wrapper for other hooks)
|
|
20
|
+
# MATCHER: Any
|
|
21
|
+
|
|
22
|
+
TARGET_HOOK="$1"
|
|
23
|
+
shift
|
|
24
|
+
|
|
25
|
+
if [ -z "$TARGET_HOOK" ] || [ ! -f "$TARGET_HOOK" ]; then
|
|
26
|
+
echo "Usage: hook-stdout-sanitizer.sh <path-to-hook.sh>" >&2
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Capture stdin (tool input)
|
|
31
|
+
TOOL_INPUT=$(cat)
|
|
32
|
+
|
|
33
|
+
# Run the target hook, capturing stdout and stderr separately
|
|
34
|
+
STDOUT_FILE=$(mktemp)
|
|
35
|
+
STDERR_FILE=$(mktemp)
|
|
36
|
+
echo "$TOOL_INPUT" | bash "$TARGET_HOOK" "$@" > "$STDOUT_FILE" 2> "$STDERR_FILE"
|
|
37
|
+
EXIT_CODE=$?
|
|
38
|
+
|
|
39
|
+
# Forward stderr (safe — always goes to user)
|
|
40
|
+
cat "$STDERR_FILE" >&2
|
|
41
|
+
|
|
42
|
+
# Only forward stdout if it looks like valid hookSpecificOutput JSON
|
|
43
|
+
STDOUT_CONTENT=$(cat "$STDOUT_FILE")
|
|
44
|
+
if [ -n "$STDOUT_CONTENT" ]; then
|
|
45
|
+
# Check if it's valid JSON with hookSpecificOutput
|
|
46
|
+
if echo "$STDOUT_CONTENT" | jq -e '.hookSpecificOutput' &>/dev/null 2>&1 || \
|
|
47
|
+
echo "$STDOUT_CONTENT" | jq -e '.permissionDecision' &>/dev/null 2>&1 || \
|
|
48
|
+
echo "$STDOUT_CONTENT" | jq -e '.systemMessage' &>/dev/null 2>&1; then
|
|
49
|
+
# Valid hook output — forward to stdout
|
|
50
|
+
echo "$STDOUT_CONTENT"
|
|
51
|
+
else
|
|
52
|
+
# Not valid hook JSON — redirect to stderr to prevent corruption
|
|
53
|
+
echo "⚠ hook-stdout-sanitizer: redirected non-JSON stdout to stderr" >&2
|
|
54
|
+
echo "$STDOUT_CONTENT" >&2
|
|
55
|
+
fi
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# Clean up
|
|
59
|
+
rm -f "$STDOUT_FILE" "$STDERR_FILE"
|
|
60
|
+
|
|
61
|
+
exit $EXIT_CODE
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# max-concurrent-agents.sh — Limit number of simultaneous subagents
|
|
3
|
+
#
|
|
4
|
+
# Solves: Uncontrolled agent spawning burns through rate limits and tokens.
|
|
5
|
+
# A single prompt like "research 10 topics" can spawn 10 agents,
|
|
6
|
+
# each consuming context and API calls simultaneously.
|
|
7
|
+
#
|
|
8
|
+
# How it works: PreToolUse hook on "Agent" that tracks active agents
|
|
9
|
+
# via a counter file. Blocks new agents when the limit is reached.
|
|
10
|
+
# Counter is decremented by a companion PostToolUse hook or timeout.
|
|
11
|
+
#
|
|
12
|
+
# CONFIG:
|
|
13
|
+
# CC_MAX_AGENTS=3 (default: 3 concurrent agents)
|
|
14
|
+
#
|
|
15
|
+
# TRIGGER: PreToolUse
|
|
16
|
+
# MATCHER: "Agent"
|
|
17
|
+
|
|
18
|
+
INPUT=$(cat)
|
|
19
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
20
|
+
[ "$TOOL" != "Agent" ] && exit 0
|
|
21
|
+
|
|
22
|
+
MAX_AGENTS=${CC_MAX_AGENTS:-3}
|
|
23
|
+
COUNTER_FILE="/tmp/cc-agent-count-${PPID}"
|
|
24
|
+
|
|
25
|
+
# Initialize counter
|
|
26
|
+
[ -f "$COUNTER_FILE" ] || echo "0" > "$COUNTER_FILE"
|
|
27
|
+
|
|
28
|
+
# Read current count
|
|
29
|
+
CURRENT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
|
|
30
|
+
|
|
31
|
+
# Clean up stale counts (reset if file is older than 10 minutes)
|
|
32
|
+
if [ -f "$COUNTER_FILE" ]; then
|
|
33
|
+
AGE=$(( $(date +%s) - $(stat -c %Y "$COUNTER_FILE" 2>/dev/null || echo 0) ))
|
|
34
|
+
[ "$AGE" -gt 600 ] && echo "0" > "$COUNTER_FILE" && CURRENT=0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
if [ "$CURRENT" -ge "$MAX_AGENTS" ]; then
|
|
38
|
+
echo "BLOCKED: Maximum concurrent agents reached (${CURRENT}/${MAX_AGENTS})" >&2
|
|
39
|
+
echo " Wait for existing agents to complete before spawning new ones." >&2
|
|
40
|
+
echo " Set CC_MAX_AGENTS to increase the limit." >&2
|
|
41
|
+
exit 2
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Increment counter
|
|
45
|
+
echo $((CURRENT + 1)) > "$COUNTER_FILE"
|
|
46
|
+
exit 0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# mcp-config-freeze.sh — Prevent MCP configuration changes during session
|
|
3
|
+
#
|
|
4
|
+
# Solves: Shadow MCP servers added mid-session (OWASP MCP09).
|
|
5
|
+
# An agent or prompt injection could modify .mcp.json or
|
|
6
|
+
# settings.json to add unauthorized MCP servers.
|
|
7
|
+
#
|
|
8
|
+
# How it works: On SessionStart, snapshots the current MCP config.
|
|
9
|
+
# On subsequent Edit/Write to config files, compares against snapshot.
|
|
10
|
+
# Blocks changes that add new MCP servers.
|
|
11
|
+
#
|
|
12
|
+
# Complements mcp-server-guard.sh (which blocks Bash-based server launches)
|
|
13
|
+
# by also covering config file modification.
|
|
14
|
+
#
|
|
15
|
+
# TRIGGER: PreToolUse
|
|
16
|
+
# MATCHER: "Edit|Write"
|
|
17
|
+
|
|
18
|
+
INPUT=$(cat)
|
|
19
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
20
|
+
[ -z "$FILE" ] && exit 0
|
|
21
|
+
|
|
22
|
+
# Only check MCP-related config files
|
|
23
|
+
FILENAME=$(basename "$FILE")
|
|
24
|
+
case "$FILENAME" in
|
|
25
|
+
.mcp.json|mcp.json|mcp-config.json)
|
|
26
|
+
;;
|
|
27
|
+
settings.json|settings.local.json)
|
|
28
|
+
# Check if the edit adds mcpServers
|
|
29
|
+
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // .tool_input.content // empty' 2>/dev/null)
|
|
30
|
+
if echo "$CONTENT" | grep -qiE 'mcpServers|mcp_servers'; then
|
|
31
|
+
echo "BLOCKED: MCP server configuration change detected" >&2
|
|
32
|
+
echo " File: $FILE" >&2
|
|
33
|
+
echo " MCP server additions require manual approval." >&2
|
|
34
|
+
echo " Edit the file manually or remove this hook temporarily." >&2
|
|
35
|
+
exit 2
|
|
36
|
+
fi
|
|
37
|
+
exit 0
|
|
38
|
+
;;
|
|
39
|
+
*)
|
|
40
|
+
exit 0
|
|
41
|
+
;;
|
|
42
|
+
esac
|
|
43
|
+
|
|
44
|
+
# For .mcp.json files, block all modifications
|
|
45
|
+
echo "BLOCKED: MCP configuration file is frozen during this session" >&2
|
|
46
|
+
echo " File: $FILE" >&2
|
|
47
|
+
echo " To modify MCP config, edit the file manually outside Claude Code." >&2
|
|
48
|
+
exit 2
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# mcp-data-boundary.sh — Prevent MCP tools from accessing sensitive paths
|
|
3
|
+
#
|
|
4
|
+
# Solves: MCP tools can read/write files outside the intended scope.
|
|
5
|
+
# A rogue or misconfigured MCP server could exfiltrate credentials
|
|
6
|
+
# or modify system files. (OWASP MCP01 + MCP10)
|
|
7
|
+
#
|
|
8
|
+
# How it works: PostToolUse hook that checks MCP tool results for
|
|
9
|
+
# sensitive file path references. Warns if an MCP tool accessed
|
|
10
|
+
# paths outside the project directory.
|
|
11
|
+
#
|
|
12
|
+
# CONFIG:
|
|
13
|
+
# CC_MCP_ALLOWED_PATHS="/home/user/project" (colon-separated)
|
|
14
|
+
#
|
|
15
|
+
# TRIGGER: PostToolUse
|
|
16
|
+
# MATCHER: "" (monitors all tools, focuses on MCP tool outputs)
|
|
17
|
+
|
|
18
|
+
INPUT=$(cat)
|
|
19
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
20
|
+
OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // empty' 2>/dev/null | head -c 2000)
|
|
21
|
+
[ -z "$OUTPUT" ] && exit 0
|
|
22
|
+
|
|
23
|
+
# Only check MCP tool outputs (tool names starting with mcp__)
|
|
24
|
+
echo "$TOOL" | grep -q '^mcp__' || exit 0
|
|
25
|
+
|
|
26
|
+
# Check for sensitive path patterns in output
|
|
27
|
+
SENSITIVE_PATHS='/etc/passwd|/etc/shadow|\.ssh/|\.aws/|\.env|credentials|\.npmrc|\.pypirc|\.netrc|\.gnupg|\.kube/config'
|
|
28
|
+
|
|
29
|
+
if echo "$OUTPUT" | grep -qiE "$SENSITIVE_PATHS"; then
|
|
30
|
+
echo "⚠ MCP DATA BOUNDARY: MCP tool accessed sensitive path" >&2
|
|
31
|
+
echo " Tool: $TOOL" >&2
|
|
32
|
+
echo " Detected sensitive path reference in output." >&2
|
|
33
|
+
echo " Review the MCP server's file access scope." >&2
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Check for data that looks like secrets in output
|
|
37
|
+
if echo "$OUTPUT" | grep -qE 'sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{30,}|-----BEGIN.*KEY|AKIA[A-Z0-9]{16}'; then
|
|
38
|
+
echo "⚠ MCP DATA BOUNDARY: MCP tool output contains potential secrets" >&2
|
|
39
|
+
echo " Tool: $TOOL" >&2
|
|
40
|
+
echo " Review output for leaked credentials." >&2
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
exit 0
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# no-git-amend.sh — Block git commit --amend to prevent overwriting previous commits
|
|
3
|
+
#
|
|
4
|
+
# Solves: Claude Code amending previous commits instead of creating new ones.
|
|
5
|
+
# When a pre-commit hook fails, the commit doesn't happen. If Claude
|
|
6
|
+
# then runs --amend to "fix" it, it modifies the PREVIOUS commit
|
|
7
|
+
# instead of creating a new one — potentially destroying prior work.
|
|
8
|
+
#
|
|
9
|
+
# This is explicitly recommended in Claude Code's own system prompt:
|
|
10
|
+
# "Always create NEW commits rather than amending"
|
|
11
|
+
#
|
|
12
|
+
# TRIGGER: PreToolUse
|
|
13
|
+
# MATCHER: "Bash"
|
|
14
|
+
|
|
15
|
+
INPUT=$(cat)
|
|
16
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
17
|
+
[ -z "$COMMAND" ] && exit 0
|
|
18
|
+
|
|
19
|
+
# Block git commit --amend
|
|
20
|
+
if echo "$COMMAND" | grep -qE 'git\s+commit\s+.*--amend|git\s+commit\s+--amend'; then
|
|
21
|
+
echo "BLOCKED: git commit --amend is not allowed" >&2
|
|
22
|
+
echo " Create a new commit instead: git commit -m 'fix: ...'" >&2
|
|
23
|
+
echo " Amending can overwrite the previous commit's changes." >&2
|
|
24
|
+
exit 2
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
exit 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# path-deny-bash-guard.sh — Enforce path deny rules on Bash commands
|
|
3
|
+
#
|
|
4
|
+
# Solves: Bash tool bypasses settings.json path deny rules (#39987).
|
|
5
|
+
# Read/Glob/Grep respect deny rules, but Bash commands like
|
|
6
|
+
# `cat /denied/path/file.txt` or `grep pattern /denied/path/`
|
|
7
|
+
# bypass the restriction entirely.
|
|
8
|
+
#
|
|
9
|
+
# How it works: Reads denied paths from CC_DENIED_PATHS env var or
|
|
10
|
+
# a config file, then checks if any Bash command argument contains
|
|
11
|
+
# a denied path.
|
|
12
|
+
#
|
|
13
|
+
# CONFIG:
|
|
14
|
+
# CC_DENIED_PATHS="/path/one:/path/two:/path/three"
|
|
15
|
+
# Or create ~/.claude/denied-paths.txt (one path per line)
|
|
16
|
+
#
|
|
17
|
+
# TRIGGER: PreToolUse
|
|
18
|
+
# MATCHER: "Bash"
|
|
19
|
+
|
|
20
|
+
INPUT=$(cat)
|
|
21
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
22
|
+
[ -z "$COMMAND" ] && exit 0
|
|
23
|
+
|
|
24
|
+
# Load denied paths
|
|
25
|
+
DENIED_PATHS=""
|
|
26
|
+
|
|
27
|
+
# Source 1: Environment variable (colon-separated)
|
|
28
|
+
if [ -n "${CC_DENIED_PATHS:-}" ]; then
|
|
29
|
+
DENIED_PATHS="$CC_DENIED_PATHS"
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Source 2: Config file (one path per line)
|
|
33
|
+
DENY_FILE="${HOME}/.claude/denied-paths.txt"
|
|
34
|
+
if [ -f "$DENY_FILE" ]; then
|
|
35
|
+
while IFS= read -r line; do
|
|
36
|
+
[ -z "$line" ] && continue
|
|
37
|
+
[[ "$line" =~ ^# ]] && continue
|
|
38
|
+
if [ -n "$DENIED_PATHS" ]; then
|
|
39
|
+
DENIED_PATHS="${DENIED_PATHS}:${line}"
|
|
40
|
+
else
|
|
41
|
+
DENIED_PATHS="$line"
|
|
42
|
+
fi
|
|
43
|
+
done < "$DENY_FILE"
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
[ -z "$DENIED_PATHS" ] && exit 0
|
|
47
|
+
|
|
48
|
+
# Check command against denied paths
|
|
49
|
+
IFS=':'
|
|
50
|
+
for denied_path in $DENIED_PATHS; do
|
|
51
|
+
[ -z "$denied_path" ] && continue
|
|
52
|
+
# Normalize: remove trailing slash
|
|
53
|
+
denied_path="${denied_path%/}"
|
|
54
|
+
|
|
55
|
+
if echo "$COMMAND" | grep -qF "$denied_path"; then
|
|
56
|
+
echo "BLOCKED: Bash command accesses denied path" >&2
|
|
57
|
+
echo " Denied: $denied_path" >&2
|
|
58
|
+
echo " Command: $(echo "$COMMAND" | head -c 100)" >&2
|
|
59
|
+
echo " Note: This path is restricted in your deny rules." >&2
|
|
60
|
+
echo " Use Read/Glob/Grep tools which respect deny rules," >&2
|
|
61
|
+
echo " or remove the path from denied-paths.txt." >&2
|
|
62
|
+
exit 2
|
|
63
|
+
fi
|
|
64
|
+
done
|
|
65
|
+
|
|
66
|
+
exit 0
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# permission-mode-drift-guard.sh — Detect permission mode changes mid-session
|
|
3
|
+
#
|
|
4
|
+
# Solves: Permission mode resets from 'Bypass permissions' to 'Edit automatically'
|
|
5
|
+
# mid-session without user interaction (#39057, 3 reactions).
|
|
6
|
+
#
|
|
7
|
+
# How it works: On SessionStart, records the initial permission mode.
|
|
8
|
+
# On each PermissionRequest, compares current behavior against expected.
|
|
9
|
+
# If permissions are being requested when bypass mode was set,
|
|
10
|
+
# warns the user that the mode may have drifted.
|
|
11
|
+
#
|
|
12
|
+
# Uses ConfigChange hook event (v2.1.83+) when available, falls back
|
|
13
|
+
# to heuristic detection via unexpected permission prompts.
|
|
14
|
+
#
|
|
15
|
+
# TRIGGER: PermissionRequest (fallback detection)
|
|
16
|
+
# MATCHER: "" (all permission requests)
|
|
17
|
+
|
|
18
|
+
INPUT=$(cat)
|
|
19
|
+
MESSAGE=$(echo "$INPUT" | jq -r '.message // empty' 2>/dev/null)
|
|
20
|
+
|
|
21
|
+
STATE_FILE="/tmp/cc-permission-mode-${PPID}"
|
|
22
|
+
|
|
23
|
+
# On first call, record that we're getting permission prompts
|
|
24
|
+
if [ ! -f "$STATE_FILE" ]; then
|
|
25
|
+
echo "prompt_count=1" > "$STATE_FILE"
|
|
26
|
+
echo "first_prompt=$(date +%s)" >> "$STATE_FILE"
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Track prompt count
|
|
31
|
+
. "$STATE_FILE"
|
|
32
|
+
prompt_count=$((prompt_count + 1))
|
|
33
|
+
echo "prompt_count=$prompt_count" > "$STATE_FILE"
|
|
34
|
+
echo "first_prompt=$first_prompt" >> "$STATE_FILE"
|
|
35
|
+
|
|
36
|
+
# If we're getting many permission prompts, something may be wrong
|
|
37
|
+
if [ "$prompt_count" -eq 5 ]; then
|
|
38
|
+
echo "⚠ Permission mode drift detected: ${prompt_count} permission prompts this session" >&2
|
|
39
|
+
echo " If you set 'Bypass permissions', it may have reset to 'Edit automatically'" >&2
|
|
40
|
+
echo " Check: Ctrl+Shift+P → Claude Code: Set Permission Mode" >&2
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
if [ "$prompt_count" -eq 20 ]; then
|
|
44
|
+
echo "⚠ ${prompt_count} permission prompts — consider re-enabling bypass mode" >&2
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
exit 0
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# plan-mode-strict-guard.sh — Hard-block all write operations during plan mode
|
|
3
|
+
#
|
|
4
|
+
# Solves: Plan mode doesn't hard-block write tools (#40324, 0 reactions).
|
|
5
|
+
# When Claude is in plan mode, it should only read and analyze.
|
|
6
|
+
# But the model can propose Edit/Write operations, and if the user
|
|
7
|
+
# clicks "approve", they execute — defeating the purpose of plan mode.
|
|
8
|
+
#
|
|
9
|
+
# How it works: Checks for plan mode indicators:
|
|
10
|
+
# 1. CC_PLAN_MODE env var (set by some configurations)
|
|
11
|
+
# 2. .claude/plan-mode.lock file (created by plan-mode-enforcer.sh)
|
|
12
|
+
# 3. Plan-related keywords in the session context
|
|
13
|
+
#
|
|
14
|
+
# When plan mode is active, blocks Edit, Write, and dangerous Bash commands.
|
|
15
|
+
# Read, Glob, Grep, and safe Bash commands are allowed.
|
|
16
|
+
#
|
|
17
|
+
# TRIGGER: PreToolUse
|
|
18
|
+
# MATCHER: "Edit|Write|Bash"
|
|
19
|
+
|
|
20
|
+
INPUT=$(cat)
|
|
21
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
22
|
+
[ -z "$TOOL" ] && exit 0
|
|
23
|
+
|
|
24
|
+
# Check if plan mode is active
|
|
25
|
+
PLAN_MODE=false
|
|
26
|
+
|
|
27
|
+
# Method 1: Environment variable
|
|
28
|
+
[ "${CC_PLAN_MODE:-}" = "true" ] && PLAN_MODE=true
|
|
29
|
+
|
|
30
|
+
# Method 2: Lock file
|
|
31
|
+
[ -f "${HOME}/.claude/plan-mode.lock" ] && PLAN_MODE=true
|
|
32
|
+
|
|
33
|
+
# Method 3: Project-level plan lock
|
|
34
|
+
[ -f ".claude/plan-mode.lock" ] && PLAN_MODE=true
|
|
35
|
+
|
|
36
|
+
[ "$PLAN_MODE" = "false" ] && exit 0
|
|
37
|
+
|
|
38
|
+
# Plan mode is active — enforce read-only
|
|
39
|
+
case "$TOOL" in
|
|
40
|
+
Edit|Write)
|
|
41
|
+
echo "BLOCKED: Plan mode is active — write operations are not allowed" >&2
|
|
42
|
+
echo " Exit plan mode first, then make changes" >&2
|
|
43
|
+
echo " Remove ~/.claude/plan-mode.lock or unset CC_PLAN_MODE" >&2
|
|
44
|
+
exit 2
|
|
45
|
+
;;
|
|
46
|
+
Bash)
|
|
47
|
+
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
48
|
+
[ -z "$CMD" ] && exit 0
|
|
49
|
+
|
|
50
|
+
# Allow read-only commands in plan mode
|
|
51
|
+
# Strip compound operators to get the first command
|
|
52
|
+
FIRST_PART=$(echo "$CMD" | sed 's/[;&|].*//' | sed 's/^\s*//')
|
|
53
|
+
|
|
54
|
+
# Single-word safe commands
|
|
55
|
+
FIRST_WORD=$(echo "$FIRST_PART" | awk '{print $1}')
|
|
56
|
+
SAFE_SINGLE="ls|cat|head|tail|grep|find|wc|diff|pwd|echo|date|which|type|file|tree|du|df|env|printenv"
|
|
57
|
+
if echo "$FIRST_WORD" | grep -qxE "$SAFE_SINGLE"; then
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
# Two-word safe commands (git, npm, etc.)
|
|
62
|
+
FIRST_TWO=$(echo "$FIRST_PART" | awk '{print $1, $2}')
|
|
63
|
+
SAFE_TWO="git status|git log|git diff|git branch|git show|git rev-parse|git tag|node -v|python3 -V|npm list|npm outdated|pip list|pip show"
|
|
64
|
+
if echo "$FIRST_TWO" | grep -qxE "$SAFE_TWO"; then
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# Block write commands in plan mode
|
|
69
|
+
echo "BLOCKED: Plan mode is active — only read-only Bash commands allowed" >&2
|
|
70
|
+
echo " Allowed: ls, cat, grep, git status/log/diff, etc." >&2
|
|
71
|
+
exit 2
|
|
72
|
+
;;
|
|
73
|
+
esac
|
|
74
|
+
|
|
75
|
+
exit 0
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# sandbox-write-verify.sh — Verify file existence before overwrite in sandbox mode
|
|
3
|
+
#
|
|
4
|
+
# Solves: Sandbox half-broken — writes hit real filesystem (#40321).
|
|
5
|
+
# When sandbox reads are isolated but writes pass through,
|
|
6
|
+
# Claude overwrites real files it can't see, destroying projects.
|
|
7
|
+
#
|
|
8
|
+
# How it works: Before Edit/Write operations, checks if the target file
|
|
9
|
+
# exists on the REAL filesystem (not sandboxed). If Claude is about to
|
|
10
|
+
# overwrite an existing file and can't read it (sandbox read isolation),
|
|
11
|
+
# blocks the write.
|
|
12
|
+
#
|
|
13
|
+
# Also detects bulk writes (>10 files in quick succession) which is
|
|
14
|
+
# a sign of runaway overwrite behavior.
|
|
15
|
+
#
|
|
16
|
+
# TRIGGER: PreToolUse
|
|
17
|
+
# MATCHER: "Edit|Write"
|
|
18
|
+
|
|
19
|
+
INPUT=$(cat)
|
|
20
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
21
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
22
|
+
[ -z "$FILE" ] && exit 0
|
|
23
|
+
|
|
24
|
+
# Track writes per session to detect bulk overwrite
|
|
25
|
+
WRITE_LOG="/tmp/cc-sandbox-writes-${PPID}"
|
|
26
|
+
echo "$(date +%s) $FILE" >> "$WRITE_LOG" 2>/dev/null
|
|
27
|
+
|
|
28
|
+
# Check for bulk writes (>10 in last 60 seconds)
|
|
29
|
+
if [ -f "$WRITE_LOG" ]; then
|
|
30
|
+
NOW=$(date +%s)
|
|
31
|
+
RECENT=$(awk -v now="$NOW" '$1 > now - 60 {count++} END {print count+0}' "$WRITE_LOG")
|
|
32
|
+
if [ "$RECENT" -gt 10 ]; then
|
|
33
|
+
echo "BLOCKED: Bulk write detected (${RECENT} files in 60s)" >&2
|
|
34
|
+
echo " This may indicate a sandbox read/write mismatch." >&2
|
|
35
|
+
echo " Verify sandbox state before continuing." >&2
|
|
36
|
+
exit 2
|
|
37
|
+
fi
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Check if target file exists and is non-empty (potential overwrite)
|
|
41
|
+
if [ -f "$FILE" ] && [ -s "$FILE" ]; then
|
|
42
|
+
# File exists. Check if it's in a project directory
|
|
43
|
+
DIR=$(dirname "$FILE")
|
|
44
|
+
# Count files in the same directory that were recently written
|
|
45
|
+
DIR_WRITES=$(grep -c "$DIR" "$WRITE_LOG" 2>/dev/null || echo 0)
|
|
46
|
+
if [ "$DIR_WRITES" -gt 5 ]; then
|
|
47
|
+
echo "WARNING: ${DIR_WRITES} writes to $(basename "$DIR")/ — verify sandbox state" >&2
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
exit 0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# session-resume-guard.sh — Verify context is loaded after session resume
|
|
3
|
+
#
|
|
4
|
+
# Solves: Session resume loads zero conversation history (#40319).
|
|
5
|
+
# When --continue resumes a long session, cache_read_input_tokens
|
|
6
|
+
# can drop from 434k to 0, silently losing all context.
|
|
7
|
+
#
|
|
8
|
+
# How it works: On SessionStart, checks if this is a resumed session
|
|
9
|
+
# (via CC_RESUME or --continue flag indicators). If so, verifies that
|
|
10
|
+
# key context files exist and warns if they might be stale.
|
|
11
|
+
#
|
|
12
|
+
# Also saves a "session handoff" file on Stop, so the next session
|
|
13
|
+
# can detect if context was properly transferred.
|
|
14
|
+
#
|
|
15
|
+
# TRIGGER: Notification (SessionStart)
|
|
16
|
+
# MATCHER: "" (fires on all notifications, filters for SessionStart internally)
|
|
17
|
+
|
|
18
|
+
INPUT=$(cat)
|
|
19
|
+
EVENT=$(echo "$INPUT" | jq -r '.event // empty' 2>/dev/null)
|
|
20
|
+
|
|
21
|
+
HANDOFF_DIR="${HOME}/.claude/handoff"
|
|
22
|
+
mkdir -p "$HANDOFF_DIR"
|
|
23
|
+
HANDOFF_FILE="${HANDOFF_DIR}/last-session.md"
|
|
24
|
+
|
|
25
|
+
case "$EVENT" in
|
|
26
|
+
session_start|SessionStart)
|
|
27
|
+
# Check if this is a resume (handoff file exists and is recent)
|
|
28
|
+
if [ -f "$HANDOFF_FILE" ]; then
|
|
29
|
+
AGE_SECONDS=$(( $(date +%s) - $(stat -c %Y "$HANDOFF_FILE" 2>/dev/null || echo 0) ))
|
|
30
|
+
|
|
31
|
+
if [ "$AGE_SECONDS" -lt 3600 ]; then
|
|
32
|
+
echo "📋 Resuming from previous session (handoff ${AGE_SECONDS}s ago)" >&2
|
|
33
|
+
echo " Last session state:" >&2
|
|
34
|
+
head -5 "$HANDOFF_FILE" >&2
|
|
35
|
+
else
|
|
36
|
+
AGE_HOURS=$((AGE_SECONDS / 3600))
|
|
37
|
+
echo "⚠ Previous session handoff is ${AGE_HOURS}h old" >&2
|
|
38
|
+
echo " Context may be stale. Consider starting fresh." >&2
|
|
39
|
+
fi
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Check for recovery snapshots (from compaction-transcript-guard)
|
|
43
|
+
RECOVERY_DIR="${HOME}/.claude/recovery"
|
|
44
|
+
if [ -d "$RECOVERY_DIR" ]; then
|
|
45
|
+
LATEST=$(ls -t "$RECOVERY_DIR"/pre-compact-*.md 2>/dev/null | head -1)
|
|
46
|
+
if [ -n "$LATEST" ]; then
|
|
47
|
+
AGE=$(( $(date +%s) - $(stat -c %Y "$LATEST" 2>/dev/null || echo 0) ))
|
|
48
|
+
if [ "$AGE" -lt 7200 ]; then
|
|
49
|
+
echo "📸 Recent compaction recovery snapshot found ($(( AGE / 60 ))m ago)" >&2
|
|
50
|
+
echo " Path: $LATEST" >&2
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
53
|
+
fi
|
|
54
|
+
;;
|
|
55
|
+
|
|
56
|
+
session_end|Stop)
|
|
57
|
+
# Save handoff for next session
|
|
58
|
+
cat > "$HANDOFF_FILE" << EOF
|
|
59
|
+
# Session Handoff
|
|
60
|
+
Timestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
|
61
|
+
Working directory: $(pwd)
|
|
62
|
+
Branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'N/A')
|
|
63
|
+
Uncommitted: $(git status --porcelain 2>/dev/null | wc -l) files
|
|
64
|
+
Last commit: $(git log --oneline -1 2>/dev/null || echo 'N/A')
|
|
65
|
+
EOF
|
|
66
|
+
echo "📋 Session handoff saved for next resume" >&2
|
|
67
|
+
;;
|
|
68
|
+
esac
|
|
69
|
+
|
|
70
|
+
exit 0
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# subagent-scope-validator.sh — Validate subagent task scope before launch
|
|
3
|
+
#
|
|
4
|
+
# Solves: Main agent's subagent delegation produces poor scoping (#40339).
|
|
5
|
+
# Subagents are launched with vague prompts, missing context,
|
|
6
|
+
# and no result verification criteria.
|
|
7
|
+
#
|
|
8
|
+
# How it works: PreToolUse hook on "Agent" that checks the prompt
|
|
9
|
+
# for minimum scope requirements:
|
|
10
|
+
# 1. Prompt must be longer than 50 characters (not just "do X")
|
|
11
|
+
# 2. Must contain file paths or specific identifiers
|
|
12
|
+
# 3. Warns if no success criteria are mentioned
|
|
13
|
+
#
|
|
14
|
+
# TRIGGER: PreToolUse
|
|
15
|
+
# MATCHER: "Agent"
|
|
16
|
+
|
|
17
|
+
INPUT=$(cat)
|
|
18
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
19
|
+
[ "$TOOL" != "Agent" ] && exit 0
|
|
20
|
+
|
|
21
|
+
PROMPT=$(echo "$INPUT" | jq -r '.tool_input.prompt // empty' 2>/dev/null)
|
|
22
|
+
[ -z "$PROMPT" ] && exit 0
|
|
23
|
+
|
|
24
|
+
PROMPT_LEN=${#PROMPT}
|
|
25
|
+
WARNINGS=""
|
|
26
|
+
|
|
27
|
+
# Check 1: Minimum prompt length
|
|
28
|
+
if [ "$PROMPT_LEN" -lt 50 ]; then
|
|
29
|
+
WARNINGS="${WARNINGS}\n - Prompt is only ${PROMPT_LEN} chars. Subagents need detailed context (50+ chars recommended)"
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Check 2: Contains specific identifiers (files, functions, paths)
|
|
33
|
+
if ! echo "$PROMPT" | grep -qE '/[a-zA-Z]|\.ts|\.py|\.js|\.md|\.json|\.sh|function |class |def |const |let |var '; then
|
|
34
|
+
WARNINGS="${WARNINGS}\n - No file paths or code identifiers found. Subagent may lack context"
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Check 3: Success criteria
|
|
38
|
+
if ! echo "$PROMPT" | grep -qiE 'verify|confirm|test|check|ensure|must|should|expect|return|report'; then
|
|
39
|
+
WARNINGS="${WARNINGS}\n - No success criteria detected. Consider adding verification steps"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Output warnings (don't block — just inform)
|
|
43
|
+
if [ -n "$WARNINGS" ]; then
|
|
44
|
+
echo "⚠ Subagent scope review:" >&2
|
|
45
|
+
echo -e "$WARNINGS" >&2
|
|
46
|
+
echo " Prompt preview: $(echo "$PROMPT" | head -c 100)..." >&2
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
exit 0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# windows-path-guard.sh — Prevent NTFS junction/symlink traversal destruction
|
|
3
|
+
#
|
|
4
|
+
# Solves: rm -rf following NTFS junctions to delete user directories (#36339).
|
|
5
|
+
# On Windows (WSL/Git Bash), `rm -rf` can traverse NTFS junctions
|
|
6
|
+
# and delete system directories like C:\Users.
|
|
7
|
+
#
|
|
8
|
+
# How it works: Before rm operations, checks if the target path is
|
|
9
|
+
# a symlink or junction that points outside the project directory.
|
|
10
|
+
# Blocks rm if it would traverse to a system-critical location.
|
|
11
|
+
#
|
|
12
|
+
# TRIGGER: PreToolUse
|
|
13
|
+
# MATCHER: "Bash"
|
|
14
|
+
|
|
15
|
+
INPUT=$(cat)
|
|
16
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
17
|
+
[ -z "$COMMAND" ] && exit 0
|
|
18
|
+
|
|
19
|
+
# Only check rm commands
|
|
20
|
+
echo "$COMMAND" | grep -qE '^\s*rm\s|;\s*rm\s|&&\s*rm\s' || exit 0
|
|
21
|
+
|
|
22
|
+
# Check the entire command for Windows system paths
|
|
23
|
+
# This catches both direct paths and quoted paths with spaces
|
|
24
|
+
WINDOWS_SYSTEM='/mnt/[a-z]/(Users|Windows|Program Files|Program)'
|
|
25
|
+
if echo "$COMMAND" | grep -qiE "$WINDOWS_SYSTEM"; then
|
|
26
|
+
echo "BLOCKED: rm targets Windows system directory" >&2
|
|
27
|
+
echo " Command contains a Windows system path reference." >&2
|
|
28
|
+
echo " This could destroy system files via NTFS junction traversal." >&2
|
|
29
|
+
echo " Reference: GitHub Issue #36339" >&2
|
|
30
|
+
exit 2
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Check if any rm target is a symlink pointing to system directories
|
|
34
|
+
for target in $(echo "$COMMAND" | grep -oE '/[^ ";\|&]+' | head -10); do
|
|
35
|
+
[ -L "$target" ] || [ -L "$(dirname "$target" 2>/dev/null)" ] || continue
|
|
36
|
+
LINK_TARGET=$(readlink -f "$target" 2>/dev/null)
|
|
37
|
+
[ -z "$LINK_TARGET" ] && continue
|
|
38
|
+
if echo "$LINK_TARGET" | grep -qiE "^/(mnt/[a-z]/(Users|Windows|Program)|home$|etc$|usr$|var$)"; then
|
|
39
|
+
echo "BLOCKED: rm would traverse symlink/junction to system directory" >&2
|
|
40
|
+
echo " Target: $target → $LINK_TARGET" >&2
|
|
41
|
+
exit 2
|
|
42
|
+
fi
|
|
43
|
+
done
|
|
44
|
+
|
|
45
|
+
exit 0
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-safe-setup",
|
|
3
|
-
"version": "29.6.
|
|
4
|
-
"description": "One command to make Claude Code safe.
|
|
3
|
+
"version": "29.6.32",
|
|
4
|
+
"description": "One command to make Claude Code safe. 539 example hooks + 8 built-in. 56 CLI commands. 7789 tests. Works with Auto Mode.",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cc-safe-setup": "index.mjs"
|
package/test.sh.tmp
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
echo "path-deny-bash-guard.sh:"
|
|
2
|
+
test_ex path-deny-bash-guard.sh '{"tool_input":{"command":"cat /etc/passwd"}}' 0 "path-deny: no deny config"
|
|
3
|
+
test_ex path-deny-bash-guard.sh '{}' 0 "path-deny: empty input"
|
|
4
|
+
export CC_DENIED_PATHS="/secret/data:/private/keys"
|
|
5
|
+
test_ex path-deny-bash-guard.sh '{"tool_input":{"command":"cat /secret/data/file.txt"}}' 2 "path-deny: cat denied path BLOCKED"
|
|
6
|
+
test_ex path-deny-bash-guard.sh '{"tool_input":{"command":"grep pattern /secret/data/"}}' 2 "path-deny: grep denied path BLOCKED"
|
|
7
|
+
test_ex path-deny-bash-guard.sh '{"tool_input":{"command":"head /private/keys/id_rsa"}}' 2 "path-deny: head denied path BLOCKED"
|
|
8
|
+
test_ex path-deny-bash-guard.sh '{"tool_input":{"command":"ls /home/user/projects"}}' 0 "path-deny: safe path allowed"
|
|
9
|
+
test_ex path-deny-bash-guard.sh '{"tool_input":{"command":"echo hello"}}' 0 "path-deny: no path in command"
|
|
10
|
+
test_ex path-deny-bash-guard.sh '{"tool_input":{"command":"cat /secret/data/../../../etc/passwd"}}' 2 "path-deny: traversal still matches denied prefix"
|
|
11
|
+
unset CC_DENIED_PATHS
|
|
12
|
+
echo ""
|