cc-safe-setup 29.6.39 → 29.7.0
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/.claude-plugin/marketplace.json +66 -0
- package/.claude-plugin/plugin.json +11 -0
- package/README.md +133 -12
- package/SETTINGS_REFERENCE.md +2 -0
- package/SKILL.md +47 -0
- package/TROUBLESHOOTING.md +26 -0
- package/examples/README.md +11 -1
- package/examples/activity-logger.sh +58 -0
- package/examples/allow-claude-settings.sh +3 -2
- package/examples/allow-git-hooks-dir.sh +3 -2
- package/examples/allow-protected-dirs.sh +3 -2
- package/examples/auto-approve-compound-git.sh +3 -0
- package/examples/auto-compact-context-monitor.sh +35 -0
- package/examples/auto-mode-safety-enforcer.sh +57 -0
- package/examples/background-task-guard.sh +57 -0
- package/examples/bash-heuristic-approver.sh +1 -1
- package/examples/broad-find-guard.sh +62 -0
- package/examples/cache-creation-spike-detector.sh +32 -0
- package/examples/case-insensitive-path-guard.sh +96 -0
- package/examples/cjk-punctuation-guard.sh +44 -0
- package/examples/clipboard-secret-guard.sh +29 -0
- package/examples/context-size-alert.sh +38 -0
- package/examples/context-usage-drift-alert.sh +33 -0
- package/examples/dangerous-pip-flag-guard.sh +51 -0
- package/examples/decision-warn.sh +59 -0
- package/examples/deny-bypass-detector.sh +143 -0
- package/examples/direnv-auto-reload.sh +9 -2
- package/examples/dotenv-commit-guard.sh +11 -5
- package/examples/dotenv-read-guard.sh +48 -0
- package/examples/dotfile-protection-guard.sh +60 -0
- package/examples/effort-tracking-logger.sh +30 -0
- package/examples/financial-operation-guard.sh +47 -0
- package/examples/full-rewrite-detector.sh +63 -0
- package/examples/home-critical-bash-guard.sh +56 -0
- package/examples/idle-session-cost-alert.sh +36 -0
- package/examples/model-version-alert.sh +18 -0
- package/examples/model-version-change-alert.sh +31 -0
- package/examples/move-delete-sequence-guard.sh +92 -0
- package/examples/pii-upload-guard.sh +72 -0
- package/examples/pr-duplicate-guard.sh +14 -0
- package/examples/production-port-kill-guard.sh +60 -0
- package/examples/proof-log-session.sh +62 -0
- package/examples/quota-reset-cycle-monitor.sh +30 -0
- package/examples/repo-visibility-guard.sh +33 -0
- package/examples/sandbox-relative-path-audit.sh +51 -0
- package/examples/session-agent-cost-limiter.sh +43 -0
- package/examples/session-cost-alert.sh +62 -0
- package/examples/session-memory-watchdog.sh +9 -0
- package/examples/settings-integrity-monitor.sh +55 -0
- package/examples/settings-json-model-guard.sh +89 -0
- package/examples/shell-config-truncation-guard.sh +97 -0
- package/examples/shell-wrapper-guard.sh +4 -4
- package/examples/subagent-spawn-rate-monitor.sh +34 -0
- package/examples/subcommand-chain-guard.sh +44 -0
- package/examples/system-dir-protection-guard.sh +100 -0
- package/examples/thinking-display-enforcer.sh +25 -0
- package/examples/tool-retry-budget-guard.sh +59 -0
- package/examples/worktree-branch-pollution-detector.sh +35 -0
- package/examples/worktree-create-log.sh +6 -0
- package/examples/worktree-hook-linker.sh +72 -0
- package/examples/worktree-remove-uncommitted-guard.sh +20 -0
- package/hooks/hooks.json +60 -0
- package/index.mjs +108 -6
- package/memory/market-anthropic-japan-strategy-2026-04-13.md +4 -0
- package/package.json +2 -2
- package/plugins/credential-guard/.claude-plugin/plugin.json +58 -0
- package/plugins/git-protection/.claude-plugin/plugin.json +58 -0
- package/plugins/safety-essentials/.claude-plugin/plugin.json +58 -0
- package/plugins/token-guard/.claude-plugin/plugin.json +51 -0
- package/skills/safety-setup/SKILL.md +47 -0
- package/tests/dotenv-read-guard.test.sh +65 -0
- package/tests/test-auto-mode-safety-enforcer.sh +55 -0
- package/tests/test-case-insensitive-path-guard.sh +78 -0
- package/tests/test-context-usage-drift-alert.sh +52 -0
- package/tests/test-dangerous-pip-flag-guard.sh +56 -0
- package/tests/test-dotfile-protection-guard.sh +68 -0
- package/tests/test-effort-tracking-logger.sh +55 -0
- package/tests/test-financial-operation-guard.sh +59 -0
- package/tests/test-home-critical-bash-guard.sh +59 -0
- package/tests/test-model-version-change-alert.sh +55 -0
- package/tests/test-move-delete-sequence-guard.sh +63 -0
- package/tests/test-pr-duplicate-guard.sh +29 -0
- package/tests/test-quota-reset-cycle-monitor.sh +52 -0
- package/tests/test-shell-config-truncation-guard.sh +104 -0
- package/tests/test-subagent-spawn-rate-monitor.sh +43 -0
- package/tests/test-system-dir-protection-guard.sh +81 -0
- package/tests/test-tool-retry-budget-guard.sh +75 -0
- package/tests/test-worktree-branch-pollution-detector.sh +50 -0
- package/tests/test-worktree-lifecycle-hooks.sh +29 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# settings-json-model-guard.sh — Backup settings.json before /model changes
|
|
3
|
+
#
|
|
4
|
+
# Solves: The /model slash command rewrites settings.json completely,
|
|
5
|
+
# wiping all hook configurations and custom settings. (#46921)
|
|
6
|
+
#
|
|
7
|
+
# How it works: PreToolUse(Bash) detects commands that modify
|
|
8
|
+
# settings.json (especially from /model). Creates a timestamped
|
|
9
|
+
# backup before allowing the write. If hooks are lost after the
|
|
10
|
+
# write, the PostToolUse phase restores them.
|
|
11
|
+
#
|
|
12
|
+
# Usage: Add TWO hooks
|
|
13
|
+
#
|
|
14
|
+
# {
|
|
15
|
+
# "hooks": {
|
|
16
|
+
# "PreToolUse": [{
|
|
17
|
+
# "matcher": "Edit|Write",
|
|
18
|
+
# "hooks": [{ "type": "command", "command": "~/.claude/hooks/settings-json-model-guard.sh" }]
|
|
19
|
+
# }],
|
|
20
|
+
# "PostToolUse": [{
|
|
21
|
+
# "matcher": "Edit|Write",
|
|
22
|
+
# "hooks": [{ "type": "command", "command": "~/.claude/hooks/settings-json-model-guard.sh" }]
|
|
23
|
+
# }]
|
|
24
|
+
# }
|
|
25
|
+
# }
|
|
26
|
+
#
|
|
27
|
+
# TRIGGER: PreToolUse+PostToolUse MATCHER: "Edit|Write"
|
|
28
|
+
|
|
29
|
+
set -euo pipefail
|
|
30
|
+
|
|
31
|
+
INPUT=$(cat)
|
|
32
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
33
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
34
|
+
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty' 2>/dev/null)
|
|
35
|
+
|
|
36
|
+
# Only care about settings.json writes
|
|
37
|
+
case "$FILE" in
|
|
38
|
+
*/.claude/settings.json|*/.claude/settings.local.json) ;;
|
|
39
|
+
*) exit 0 ;;
|
|
40
|
+
esac
|
|
41
|
+
|
|
42
|
+
BACKUP_DIR="$HOME/.claude/settings-backups"
|
|
43
|
+
mkdir -p "$BACKUP_DIR"
|
|
44
|
+
|
|
45
|
+
SETTINGS_FILE="$FILE"
|
|
46
|
+
[ ! -f "$SETTINGS_FILE" ] && exit 0
|
|
47
|
+
|
|
48
|
+
# --- PreToolUse: backup before modification ---
|
|
49
|
+
if [[ "$HOOK_EVENT" == "PreToolUse" ]]; then
|
|
50
|
+
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
|
51
|
+
BASENAME=$(basename "$SETTINGS_FILE" .json)
|
|
52
|
+
BACKUP="$BACKUP_DIR/${BASENAME}-pre-model-${TIMESTAMP}.json"
|
|
53
|
+
cp "$SETTINGS_FILE" "$BACKUP"
|
|
54
|
+
|
|
55
|
+
# Count hooks in current settings
|
|
56
|
+
HOOK_COUNT=$(jq '[.hooks // {} | to_entries[] | .value[] | .hooks // [] | length] | add // 0' "$SETTINGS_FILE" 2>/dev/null || echo 0)
|
|
57
|
+
if [ "$HOOK_COUNT" -gt 0 ]; then
|
|
58
|
+
echo "Settings backup created: $BACKUP ($HOOK_COUNT hooks preserved)" >&2
|
|
59
|
+
# Store hook count for PostToolUse comparison
|
|
60
|
+
echo "$HOOK_COUNT" > "/tmp/cc-settings-hook-count-pre"
|
|
61
|
+
fi
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# --- PostToolUse: verify hooks survived ---
|
|
66
|
+
if [[ "$HOOK_EVENT" == "PostToolUse" ]]; then
|
|
67
|
+
PRE_COUNT_FILE="/tmp/cc-settings-hook-count-pre"
|
|
68
|
+
[ ! -f "$PRE_COUNT_FILE" ] && exit 0
|
|
69
|
+
|
|
70
|
+
PRE_COUNT=$(cat "$PRE_COUNT_FILE" 2>/dev/null || echo 0)
|
|
71
|
+
rm -f "$PRE_COUNT_FILE"
|
|
72
|
+
|
|
73
|
+
[ "$PRE_COUNT" -eq 0 ] && exit 0
|
|
74
|
+
|
|
75
|
+
# Count hooks after modification
|
|
76
|
+
POST_COUNT=$(jq '[.hooks // {} | to_entries[] | .value[] | .hooks // [] | length] | add // 0' "$SETTINGS_FILE" 2>/dev/null || echo 0)
|
|
77
|
+
|
|
78
|
+
if [ "$POST_COUNT" -lt "$PRE_COUNT" ]; then
|
|
79
|
+
LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/settings-pre-model-*.json 2>/dev/null | head -1)
|
|
80
|
+
echo "WARNING: Hook count dropped from $PRE_COUNT to $POST_COUNT after settings modification!" >&2
|
|
81
|
+
echo " This typically happens when /model rewrites settings.json." >&2
|
|
82
|
+
if [ -n "$LATEST_BACKUP" ]; then
|
|
83
|
+
echo " Restore hooks: jq -s '.[0] * {hooks: .[1].hooks}' '$SETTINGS_FILE' '$LATEST_BACKUP' > /tmp/merged.json && mv /tmp/merged.json '$SETTINGS_FILE'" >&2
|
|
84
|
+
fi
|
|
85
|
+
fi
|
|
86
|
+
exit 0
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
exit 0
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# shell-config-truncation-guard.sh — Block writes that would truncate shell config files
|
|
3
|
+
#
|
|
4
|
+
# Solves: Claude Code installer auto-update truncating ~/.bash_profile and
|
|
5
|
+
# ~/.zshrc to 0 bytes, destroying all user shell configuration (#49615).
|
|
6
|
+
# Also catches Claude itself attempting to overwrite these files with
|
|
7
|
+
# minimal or empty content.
|
|
8
|
+
#
|
|
9
|
+
# How it works: PreToolUse hook intercepts Write/Bash operations targeting
|
|
10
|
+
# shell config files. If the new content would be significantly shorter
|
|
11
|
+
# than the existing file (>60% reduction), the operation is blocked.
|
|
12
|
+
# Empty/near-empty writes are always blocked.
|
|
13
|
+
#
|
|
14
|
+
# TRIGGER: PreToolUse
|
|
15
|
+
# MATCHER: "Bash|Write"
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# {
|
|
19
|
+
# "hooks": {
|
|
20
|
+
# "PreToolUse": [{
|
|
21
|
+
# "matcher": "Bash|Write",
|
|
22
|
+
# "hooks": [{ "type": "command", "command": "~/.claude/hooks/shell-config-truncation-guard.sh" }]
|
|
23
|
+
# }]
|
|
24
|
+
# }
|
|
25
|
+
# }
|
|
26
|
+
|
|
27
|
+
INPUT=$(cat)
|
|
28
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
29
|
+
|
|
30
|
+
# Shell config files to protect
|
|
31
|
+
PROTECTED_FILES=(
|
|
32
|
+
"$HOME/.bashrc"
|
|
33
|
+
"$HOME/.bash_profile"
|
|
34
|
+
"$HOME/.zshrc"
|
|
35
|
+
"$HOME/.zprofile"
|
|
36
|
+
"$HOME/.profile"
|
|
37
|
+
"$HOME/.zshenv"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
check_file_truncation() {
|
|
41
|
+
local target_file="$1"
|
|
42
|
+
local new_size="$2"
|
|
43
|
+
|
|
44
|
+
for protected in "${PROTECTED_FILES[@]}"; do
|
|
45
|
+
if [ "$target_file" = "$protected" ] || [ "$(realpath "$target_file" 2>/dev/null)" = "$(realpath "$protected" 2>/dev/null)" ]; then
|
|
46
|
+
if [ ! -f "$protected" ]; then
|
|
47
|
+
return 0
|
|
48
|
+
fi
|
|
49
|
+
local current_size
|
|
50
|
+
current_size=$(wc -c < "$protected" 2>/dev/null || echo 0)
|
|
51
|
+
|
|
52
|
+
# Block if writing 0 bytes or near-empty (< 10 bytes)
|
|
53
|
+
if [ "$new_size" -lt 10 ] && [ "$current_size" -gt 50 ]; then
|
|
54
|
+
echo "BLOCKED: Attempted to truncate $protected to $new_size bytes (current: $current_size bytes)" >&2
|
|
55
|
+
echo "This would destroy your shell configuration. See: github.com/anthropics/claude-code/issues/49615" >&2
|
|
56
|
+
exit 2
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Block if >60% size reduction
|
|
60
|
+
if [ "$current_size" -gt 100 ]; then
|
|
61
|
+
local threshold=$((current_size * 40 / 100))
|
|
62
|
+
if [ "$new_size" -lt "$threshold" ]; then
|
|
63
|
+
echo "BLOCKED: Write to $protected would reduce size by >60% ($current_size → $new_size bytes)" >&2
|
|
64
|
+
echo "If intentional, back up first: cp $protected ${protected}.bak" >&2
|
|
65
|
+
exit 2
|
|
66
|
+
fi
|
|
67
|
+
fi
|
|
68
|
+
return 0
|
|
69
|
+
fi
|
|
70
|
+
done
|
|
71
|
+
return 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if [ "$TOOL" = "Write" ]; then
|
|
75
|
+
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
76
|
+
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null)
|
|
77
|
+
if [ -n "$FILE_PATH" ]; then
|
|
78
|
+
NEW_SIZE=${#CONTENT}
|
|
79
|
+
check_file_truncation "$FILE_PATH" "$NEW_SIZE"
|
|
80
|
+
fi
|
|
81
|
+
elif [ "$TOOL" = "Bash" ]; then
|
|
82
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
83
|
+
[ -z "$COMMAND" ] && exit 0
|
|
84
|
+
|
|
85
|
+
# Detect redirect truncation: > ~/.bashrc, >~/.zshrc, etc.
|
|
86
|
+
for protected in "${PROTECTED_FILES[@]}"; do
|
|
87
|
+
base=$(basename "$protected")
|
|
88
|
+
# Match: > file, >file, truncate file, : > file, echo "" > file
|
|
89
|
+
if echo "$COMMAND" | grep -qE "(^|[;&|])\s*(>|truncate\s+-s\s*0|:\s*>)\s*~?(/[^;]*)?${base}"; then
|
|
90
|
+
echo "BLOCKED: Command would truncate $protected" >&2
|
|
91
|
+
echo "If intentional, back up first: cp $protected ${protected}.bak" >&2
|
|
92
|
+
exit 2
|
|
93
|
+
fi
|
|
94
|
+
done
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
exit 0
|
|
@@ -34,8 +34,8 @@ if echo "$COMMAND" | grep -qE '(sh|bash|zsh|dash)\s+-c\s+'; then
|
|
|
34
34
|
fi
|
|
35
35
|
|
|
36
36
|
# === Check 2: Python one-liners ===
|
|
37
|
-
if echo "$COMMAND" | grep -qE 'python[23]?\s+-c\s+'; then
|
|
38
|
-
INNER=$(echo "$COMMAND" | sed -E "s/.*python[23]?\s+-c\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
|
37
|
+
if echo "$COMMAND" | grep -qE 'python[23]?(\.[0-9]+)?\s+-c\s+'; then
|
|
38
|
+
INNER=$(echo "$COMMAND" | sed -E "s/.*python[23]?(\.[0-9]+)?\s+-c\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
|
39
39
|
if echo "$INNER" | grep -qiE "os\.system\(.*($DESTRUCT_PATTERN)|subprocess\.(run|call|Popen)\(.*($DESTRUCT_PATTERN)|shutil\.rmtree\s*\(\s*['\"/~]"; then
|
|
40
40
|
echo "BLOCKED: Destructive command in Python one-liner" >&2
|
|
41
41
|
exit 2
|
|
@@ -69,9 +69,9 @@ if echo "$COMMAND" | grep -qE '(sh|bash)\s+-c\s+.*(sh|bash)\s+-c'; then
|
|
|
69
69
|
fi
|
|
70
70
|
|
|
71
71
|
# === Check 6: Pipe to shell (echo "rm -rf /" | sh) ===
|
|
72
|
-
if echo "$COMMAND" | grep -qE '\|\s*(sh|bash|zsh)\s
|
|
72
|
+
if echo "$COMMAND" | grep -qE '\|\s*(sh|bash|zsh)(\s|$)'; then
|
|
73
73
|
# Extract the piped content
|
|
74
|
-
PIPED=$(echo "$COMMAND" | sed -E 's/\s*\|\s*(sh|bash|zsh)\s
|
|
74
|
+
PIPED=$(echo "$COMMAND" | sed -E 's/\s*\|\s*(sh|bash|zsh)(\s.*)?$//')
|
|
75
75
|
if echo "$PIPED" | grep -qE "$DESTRUCT_PATTERN"; then
|
|
76
76
|
echo "BLOCKED: Destructive command piped to shell" >&2
|
|
77
77
|
exit 2
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# subagent-spawn-rate-monitor.sh — サブエージェントの過剰spawn検知
|
|
3
|
+
# Why: サブエージェントは毎回spawnされるたびに~4.7Kトークンがcache_creation
|
|
4
|
+
# (1.25xコスト)として課金される。spawn-heavyなワークフローでは線形に増大し、
|
|
5
|
+
# ユーザーが気づかないうちにquotaを消耗する (#50213, #46968)
|
|
6
|
+
# Event: PreToolUse MATCHER: Agent
|
|
7
|
+
# Action: 短時間に多数のAgent spawnがあれば警告
|
|
8
|
+
|
|
9
|
+
COUNTER_FILE="/tmp/cc-subagent-spawn-counter"
|
|
10
|
+
WINDOW_FILE="/tmp/cc-subagent-spawn-window"
|
|
11
|
+
THRESHOLD=5 # この回数を超えたら警告
|
|
12
|
+
WINDOW_SECS=300 # 5分間のウィンドウ
|
|
13
|
+
|
|
14
|
+
NOW=$(date +%s)
|
|
15
|
+
WINDOW_START=$(cat "$WINDOW_FILE" 2>/dev/null || echo "$NOW")
|
|
16
|
+
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo "0")
|
|
17
|
+
|
|
18
|
+
# ウィンドウ期限切れならリセット
|
|
19
|
+
ELAPSED=$((NOW - WINDOW_START))
|
|
20
|
+
if [ "$ELAPSED" -gt "$WINDOW_SECS" ]; then
|
|
21
|
+
COUNT=0
|
|
22
|
+
echo "$NOW" > "$WINDOW_FILE"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
COUNT=$((COUNT + 1))
|
|
26
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
27
|
+
|
|
28
|
+
if [ "$COUNT" -gt "$THRESHOLD" ]; then
|
|
29
|
+
echo "⚠ HIGH SUBAGENT SPAWN RATE: $COUNT agents spawned in ${ELAPSED}s" >&2
|
|
30
|
+
echo "Each spawn costs ~4.7K tokens at 1.25x rate (no cache_control)." >&2
|
|
31
|
+
echo "Consider batching tasks or using fewer parallel agents. See: #50213" >&2
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
exit 0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# subcommand-chain-guard.sh — Block commands with excessive subcommand chains
|
|
3
|
+
#
|
|
4
|
+
# Solves: Claude Code silently ignores deny rules when a command contains
|
|
5
|
+
# 50+ subcommands (MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50).
|
|
6
|
+
# Attackers chain 50 no-op "true" commands before a dangerous command
|
|
7
|
+
# to bypass all security checks. (Adversa AI / CVE disclosure, April 2026)
|
|
8
|
+
#
|
|
9
|
+
# How it works: Counts semicolon-separated and &&/|| chained subcommands.
|
|
10
|
+
# If the count exceeds a threshold (default: 20), blocks execution.
|
|
11
|
+
# This catches the exploit well before the 50-command limit.
|
|
12
|
+
#
|
|
13
|
+
# TRIGGER: PreToolUse
|
|
14
|
+
# MATCHER: "Bash"
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
THRESHOLD=${CC_SUBCOMMAND_LIMIT:-20}
|
|
19
|
+
|
|
20
|
+
INPUT=$(cat)
|
|
21
|
+
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
22
|
+
[ -z "$CMD" ] && exit 0
|
|
23
|
+
|
|
24
|
+
# Count subcommands: split on ; && ||
|
|
25
|
+
# Use tr to normalize separators, then count
|
|
26
|
+
SUBCOMMAND_COUNT=$(echo "$CMD" | tr ';' '\n' | tr '&' '\n' | tr '|' '\n' | grep -c '[^ ]' 2>/dev/null || echo 1)
|
|
27
|
+
|
|
28
|
+
if [ "$SUBCOMMAND_COUNT" -gt "$THRESHOLD" ]; then
|
|
29
|
+
echo "BLOCKED: Command contains $SUBCOMMAND_COUNT subcommands (limit: $THRESHOLD)." >&2
|
|
30
|
+
echo " Claude Code ignores deny rules after 50 subcommands (CVE disclosure)." >&2
|
|
31
|
+
echo " This hook blocks at $THRESHOLD to prevent security bypass." >&2
|
|
32
|
+
echo " Override: CC_SUBCOMMAND_LIMIT=100 (not recommended)" >&2
|
|
33
|
+
exit 2
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Also detect the specific attack pattern: many "true" or ":" no-ops
|
|
37
|
+
NOOP_COUNT=$(echo "$CMD" | grep -oE '\btrue\b|^:|;\s*:' | wc -l 2>/dev/null || echo 0)
|
|
38
|
+
if [ "$NOOP_COUNT" -gt 10 ]; then
|
|
39
|
+
echo "BLOCKED: Suspicious pattern — $NOOP_COUNT no-op commands detected." >&2
|
|
40
|
+
echo " This resembles the subcommand-chain attack (50x true + dangerous cmd)." >&2
|
|
41
|
+
exit 2
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
exit 0
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# system-dir-protection-guard.sh — Block destructive operations on system directories
|
|
3
|
+
#
|
|
4
|
+
# Solves: Agent deleting or moving system-level directories in auto mode
|
|
5
|
+
# - #49554: Auto mode approved deletion of system directories
|
|
6
|
+
# - #49129: rm -rf on /home subdirectories causing 50GB data loss
|
|
7
|
+
#
|
|
8
|
+
# Difference from existing hooks:
|
|
9
|
+
# rm-safety-net.sh: Blocks rm on critical paths, but only rm commands
|
|
10
|
+
# home-critical-bash-guard.sh: Protects ~/dotfiles only
|
|
11
|
+
# This hook: Blocks rm, mv, chmod -R, chown -R on ALL system directories
|
|
12
|
+
# including /home/*, /usr, /etc, /var, /opt, /root, /boot, /srv
|
|
13
|
+
# Also blocks mv of system dirs (not covered by rm-safety-net)
|
|
14
|
+
#
|
|
15
|
+
# TRIGGER: PreToolUse MATCHER: "Bash"
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
INPUT=$(cat)
|
|
20
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
21
|
+
[ -z "$COMMAND" ] && exit 0
|
|
22
|
+
|
|
23
|
+
# Check if a path is a protected system directory
|
|
24
|
+
is_system_dir() {
|
|
25
|
+
local path="$1"
|
|
26
|
+
# Remove trailing slash
|
|
27
|
+
path="${path%/}"
|
|
28
|
+
|
|
29
|
+
# Expand ~ to $HOME
|
|
30
|
+
if [[ "$path" == "~"* ]]; then
|
|
31
|
+
path="${HOME}${path#\~}"
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# Top-level system directories
|
|
35
|
+
case "$path" in
|
|
36
|
+
/|/home|/etc|/usr|/var|/opt|/root|/boot|/srv|/sys|/proc)
|
|
37
|
+
return 0 ;;
|
|
38
|
+
esac
|
|
39
|
+
|
|
40
|
+
# /home/<username> (1 level deep)
|
|
41
|
+
if echo "$path" | grep -qE '^/home/[^/]+$'; then
|
|
42
|
+
return 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# System subdirectories (e.g., /etc/nginx, /usr/local, /var/lib)
|
|
46
|
+
if echo "$path" | grep -qE '^/(etc|usr|var|opt|root|boot|srv|sys|proc)/'; then
|
|
47
|
+
return 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Critical home directories: ~/.ssh, ~/.config, ~/.local, ~/.gnupg, ~/.cache
|
|
51
|
+
if echo "$path" | grep -qE "^${HOME}/\.(ssh|config|local|gnupg|cache)(/[^/]*)?$"; then
|
|
52
|
+
return 0
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
return 1
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# --- rm / unlink on system directories ---
|
|
59
|
+
if echo "$COMMAND" | grep -qE '^\s*(sudo\s+)?(rm|unlink)\s'; then
|
|
60
|
+
# Extract targets after rm and flags
|
|
61
|
+
TARGETS=$(echo "$COMMAND" | grep -oP '(rm|unlink)\s+(-[a-zA-Z]+\s+)*\K[^;|&]+' 2>/dev/null || true)
|
|
62
|
+
for target in $TARGETS; do
|
|
63
|
+
if is_system_dir "$target"; then
|
|
64
|
+
echo "BLOCKED: Destructive operation on system directory: $target" >&2
|
|
65
|
+
echo "Command: $COMMAND" >&2
|
|
66
|
+
echo "" >&2
|
|
67
|
+
echo "System directories must not be deleted. Use specific file paths instead." >&2
|
|
68
|
+
echo "See: https://github.com/anthropics/claude-code/issues/49554" >&2
|
|
69
|
+
exit 2
|
|
70
|
+
fi
|
|
71
|
+
done
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# --- mv (moving system directories) ---
|
|
75
|
+
if echo "$COMMAND" | grep -qE '^\s*(sudo\s+)?mv\s'; then
|
|
76
|
+
# Get the source of the mv (first non-flag argument)
|
|
77
|
+
MV_SOURCE=$(echo "$COMMAND" | grep -oP 'mv\s+(-[a-zA-Z]+\s+)*\K\S+' 2>/dev/null || true)
|
|
78
|
+
if is_system_dir "$MV_SOURCE"; then
|
|
79
|
+
echo "BLOCKED: Moving system directory: $MV_SOURCE" >&2
|
|
80
|
+
echo "Command: $COMMAND" >&2
|
|
81
|
+
echo "" >&2
|
|
82
|
+
echo "System directories must not be moved." >&2
|
|
83
|
+
echo "See: https://github.com/anthropics/claude-code/issues/49554" >&2
|
|
84
|
+
exit 2
|
|
85
|
+
fi
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# --- chmod -R / chown -R on system directories ---
|
|
89
|
+
if echo "$COMMAND" | grep -qE '^\s*(sudo\s+)?(chmod|chown)\s+.*-R'; then
|
|
90
|
+
TARGETS=$(echo "$COMMAND" | grep -oP '(chmod|chown)\s+[^;|&]+' 2>/dev/null | awk '{print $NF}' || true)
|
|
91
|
+
for target in $TARGETS; do
|
|
92
|
+
if is_system_dir "$target"; then
|
|
93
|
+
echo "BLOCKED: Recursive permission change on system directory: $target" >&2
|
|
94
|
+
echo "Command: $COMMAND" >&2
|
|
95
|
+
exit 2
|
|
96
|
+
fi
|
|
97
|
+
done
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
exit 0
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# thinking-display-enforcer.sh — Opus 4.7 thinking summaries消失を検知
|
|
3
|
+
# Why: Opus 4.7でthinking displayのデフォルトがsummarized→omittedに変更された(#49268, 17👍)
|
|
4
|
+
# セッション開始時にモデルを確認し、Opus 4.7でthinkingが非表示の場合に警告する
|
|
5
|
+
# Event: Notification (セッション開始時に確認)
|
|
6
|
+
# Fix: claude --thinking-display summarized
|
|
7
|
+
|
|
8
|
+
# チェック頻度制御(100回に1回)
|
|
9
|
+
COUNTER_FILE="/tmp/thinking-display-check-counter"
|
|
10
|
+
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo "0")
|
|
11
|
+
COUNT=$((COUNT + 1))
|
|
12
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
13
|
+
[ $((COUNT % 100)) -ne 1 ] && exit 0
|
|
14
|
+
|
|
15
|
+
# settings.jsonにthinking display設定があるか確認
|
|
16
|
+
SETTINGS_FILE="${HOME}/.claude/settings.json"
|
|
17
|
+
if [ -f "$SETTINGS_FILE" ]; then
|
|
18
|
+
HAS_THINKING=$(grep -c "showThinkingSummaries\|thinkingDisplay" "$SETTINGS_FILE" 2>/dev/null || echo "0")
|
|
19
|
+
if [ "$HAS_THINKING" -eq 0 ]; then
|
|
20
|
+
echo "INFO: Opus 4.7ではthinking summariesがデフォルトで非表示です。" >&2
|
|
21
|
+
echo "修正: claude --thinking-display summarized で起動するか、settings.jsonに設定を追加してください。" >&2
|
|
22
|
+
echo "詳細: https://github.com/anthropics/claude-code/issues/49268" >&2
|
|
23
|
+
fi
|
|
24
|
+
fi
|
|
25
|
+
exit 0
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# tool-retry-budget-guard.sh — Stop Claude from wasting tokens on repeated failures
|
|
3
|
+
#
|
|
4
|
+
# Solves: #50986 — Claude fails simple UI change after 10+ attempts, wastes entire
|
|
5
|
+
# token budget. Also prevents retry spirals on any file.
|
|
6
|
+
#
|
|
7
|
+
# Tracks consecutive tool calls (Edit, Write, Bash) targeting the same file.
|
|
8
|
+
# After 5 attempts, blocks further edits and forces a different approach.
|
|
9
|
+
#
|
|
10
|
+
# TRIGGER: PreToolUse MATCHER: "Edit|Write"
|
|
11
|
+
|
|
12
|
+
INPUT=$(cat)
|
|
13
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
14
|
+
|
|
15
|
+
case "$TOOL" in
|
|
16
|
+
Edit|Write) ;;
|
|
17
|
+
*) exit 0 ;;
|
|
18
|
+
esac
|
|
19
|
+
|
|
20
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
21
|
+
[ -z "$FILE" ] && exit 0
|
|
22
|
+
|
|
23
|
+
# Use file hash as state key
|
|
24
|
+
HASH=$(echo "$FILE" | md5sum | cut -c1-8)
|
|
25
|
+
STATE_DIR="/tmp/.cc-retry-budget"
|
|
26
|
+
mkdir -p "$STATE_DIR"
|
|
27
|
+
STATE_FILE="$STATE_DIR/$HASH"
|
|
28
|
+
|
|
29
|
+
# Track attempt count and timestamp
|
|
30
|
+
NOW=$(date +%s)
|
|
31
|
+
COUNT=1
|
|
32
|
+
|
|
33
|
+
if [ -f "$STATE_FILE" ]; then
|
|
34
|
+
PREV_TIME=$(head -1 "$STATE_FILE" 2>/dev/null || echo "0")
|
|
35
|
+
PREV_COUNT=$(tail -1 "$STATE_FILE" 2>/dev/null || echo "0")
|
|
36
|
+
|
|
37
|
+
# Reset if more than 5 minutes since last attempt (new task)
|
|
38
|
+
ELAPSED=$(( NOW - PREV_TIME ))
|
|
39
|
+
if [ "$ELAPSED" -lt 300 ]; then
|
|
40
|
+
COUNT=$(( PREV_COUNT + 1 ))
|
|
41
|
+
fi
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
printf '%s\n%s\n' "$NOW" "$COUNT" > "$STATE_FILE"
|
|
45
|
+
|
|
46
|
+
if [ "$COUNT" -ge 7 ]; then
|
|
47
|
+
echo "BLOCKED: You've attempted to modify $(basename "$FILE") $COUNT times in the last 5 minutes." >&2
|
|
48
|
+
echo " This pattern wastes tokens. Stop and try a completely different approach:" >&2
|
|
49
|
+
echo " 1. Read the file first to understand current state" >&2
|
|
50
|
+
echo " 2. Use a different strategy (smaller change, different tool)" >&2
|
|
51
|
+
echo " 3. If stuck, explain the problem and ask for help" >&2
|
|
52
|
+
rm -f "$STATE_FILE"
|
|
53
|
+
exit 2
|
|
54
|
+
elif [ "$COUNT" -ge 5 ]; then
|
|
55
|
+
echo "WARNING: $COUNT consecutive edits to $(basename "$FILE") — approaching retry limit (7)." >&2
|
|
56
|
+
echo " Consider reading the file to verify your assumptions before the next attempt." >&2
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
exit 0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# worktree-branch-pollution-detector.sh — worktreeが親ブランチを汚染していないか検知
|
|
3
|
+
# Why: サブエージェントのworktree操作が親リポを予期しないブランチに移動させ、
|
|
4
|
+
# 意図しないcommit-to-mainが発生する。1週間で3回の事故報告あり (#50207)
|
|
5
|
+
# Event: PostToolUse MATCHER: Bash
|
|
6
|
+
# Action: 現在のブランチが期待値と異なる場合に警告
|
|
7
|
+
|
|
8
|
+
INPUT=$(cat)
|
|
9
|
+
|
|
10
|
+
# 期待ブランチ(セッション開始時に記録)
|
|
11
|
+
EXPECTED_BRANCH_FILE="/tmp/cc-expected-branch-$(pwd | md5sum | cut -c1-8)"
|
|
12
|
+
|
|
13
|
+
# git管理下でなければスキップ
|
|
14
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
15
|
+
|
|
16
|
+
CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)
|
|
17
|
+
[ -z "$CURRENT_BRANCH" ] && exit 0
|
|
18
|
+
|
|
19
|
+
# 初回実行時はブランチを記録
|
|
20
|
+
if [ ! -f "$EXPECTED_BRANCH_FILE" ]; then
|
|
21
|
+
echo "$CURRENT_BRANCH" > "$EXPECTED_BRANCH_FILE"
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
EXPECTED_BRANCH=$(cat "$EXPECTED_BRANCH_FILE" 2>/dev/null)
|
|
26
|
+
|
|
27
|
+
if [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then
|
|
28
|
+
echo "⚠ BRANCH CHANGED: Expected '$EXPECTED_BRANCH' but now on '$CURRENT_BRANCH'" >&2
|
|
29
|
+
echo "This may be caused by a worktree or subagent switching your branch." >&2
|
|
30
|
+
echo "Run 'git checkout $EXPECTED_BRANCH' to return. See: #50207" >&2
|
|
31
|
+
# 新しいブランチを記録(意図的な切替かもしれない)
|
|
32
|
+
echo "$CURRENT_BRANCH" > "$EXPECTED_BRANCH_FILE"
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
exit 0
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
LOGFILE="${HOME}/.claude/worktree-audit.log"
|
|
2
|
+
INFO=$(cat)
|
|
3
|
+
BRANCH=$(echo "$INFO" | jq -r '.branch // "unknown"' 2>/dev/null)
|
|
4
|
+
PATH_WT=$(echo "$INFO" | jq -r '.path // "unknown"' 2>/dev/null)
|
|
5
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] CREATE branch=$BRANCH path=$PATH_WT" >> "$LOGFILE"
|
|
6
|
+
exit 0
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# worktree-hook-linker.sh — Auto-link settings to worktrees
|
|
3
|
+
#
|
|
4
|
+
# Solves: In git worktrees, .claude/settings.json is not found because
|
|
5
|
+
# worktrees share .git but not the working directory. All hooks
|
|
6
|
+
# become silently disabled. (#46808)
|
|
7
|
+
#
|
|
8
|
+
# How it works: On SessionStart, checks if the current directory is a
|
|
9
|
+
# git worktree. If so, creates a symlink from the worktree's
|
|
10
|
+
# .claude/settings.json to the main tree's settings. This ensures
|
|
11
|
+
# hooks work identically in worktrees.
|
|
12
|
+
#
|
|
13
|
+
# {
|
|
14
|
+
# "hooks": {
|
|
15
|
+
# "Notification": [{
|
|
16
|
+
# "matcher": "SessionStart",
|
|
17
|
+
# "hooks": [{ "type": "command", "command": "~/.claude/hooks/worktree-hook-linker.sh" }]
|
|
18
|
+
# }]
|
|
19
|
+
# }
|
|
20
|
+
# }
|
|
21
|
+
#
|
|
22
|
+
# TRIGGER: Notification
|
|
23
|
+
# MATCHER: "SessionStart"
|
|
24
|
+
|
|
25
|
+
# Detect if we're in a git worktree
|
|
26
|
+
GITDIR=$(git rev-parse --git-dir 2>/dev/null) || exit 0
|
|
27
|
+
echo "$GITDIR" | grep -q "worktrees" || exit 0
|
|
28
|
+
|
|
29
|
+
# We're in a worktree — find the main working tree
|
|
30
|
+
MAIN_GITDIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || exit 0
|
|
31
|
+
MAIN_WORKDIR=$(echo "$MAIN_GITDIR" | sed 's|/.git$||')
|
|
32
|
+
|
|
33
|
+
MAIN_CLAUDE_DIR="$MAIN_WORKDIR/.claude"
|
|
34
|
+
LOCAL_CLAUDE_DIR=".claude"
|
|
35
|
+
|
|
36
|
+
# Skip if main tree has no .claude directory
|
|
37
|
+
[ -d "$MAIN_CLAUDE_DIR" ] || exit 0
|
|
38
|
+
|
|
39
|
+
# Create .claude directory in worktree if needed
|
|
40
|
+
mkdir -p "$LOCAL_CLAUDE_DIR" 2>/dev/null
|
|
41
|
+
|
|
42
|
+
# Link settings files if they exist in main but not in worktree
|
|
43
|
+
for f in settings.json settings.local.json; do
|
|
44
|
+
MAIN_FILE="$MAIN_CLAUDE_DIR/$f"
|
|
45
|
+
LOCAL_FILE="$LOCAL_CLAUDE_DIR/$f"
|
|
46
|
+
|
|
47
|
+
[ ! -f "$MAIN_FILE" ] && continue
|
|
48
|
+
|
|
49
|
+
if [ ! -e "$LOCAL_FILE" ]; then
|
|
50
|
+
ln -s "$MAIN_FILE" "$LOCAL_FILE"
|
|
51
|
+
echo "Linked $f from main tree → worktree (hooks now active)" >&2
|
|
52
|
+
elif [ -L "$LOCAL_FILE" ]; then
|
|
53
|
+
# Already a symlink — verify it points to the right place
|
|
54
|
+
TARGET=$(readlink -f "$LOCAL_FILE" 2>/dev/null)
|
|
55
|
+
EXPECTED=$(readlink -f "$MAIN_FILE" 2>/dev/null)
|
|
56
|
+
if [ "$TARGET" != "$EXPECTED" ]; then
|
|
57
|
+
rm "$LOCAL_FILE"
|
|
58
|
+
ln -s "$MAIN_FILE" "$LOCAL_FILE"
|
|
59
|
+
echo "Re-linked $f (was pointing to wrong location)" >&2
|
|
60
|
+
fi
|
|
61
|
+
fi
|
|
62
|
+
done
|
|
63
|
+
|
|
64
|
+
# Also link hooks directory if it exists
|
|
65
|
+
MAIN_HOOKS="$MAIN_CLAUDE_DIR/hooks"
|
|
66
|
+
LOCAL_HOOKS="$LOCAL_CLAUDE_DIR/hooks"
|
|
67
|
+
if [ -d "$MAIN_HOOKS" ] && [ ! -e "$LOCAL_HOOKS" ]; then
|
|
68
|
+
ln -s "$MAIN_HOOKS" "$LOCAL_HOOKS"
|
|
69
|
+
echo "Linked hooks/ directory from main tree → worktree" >&2
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
exit 0
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
INFO=$(cat)
|
|
2
|
+
PATH_WT=$(echo "$INFO" | jq -r '.path // empty' 2>/dev/null)
|
|
3
|
+
[ -z "$PATH_WT" ] && exit 0
|
|
4
|
+
[ ! -d "$PATH_WT" ] && exit 0
|
|
5
|
+
cd "$PATH_WT" 2>/dev/null || exit 0
|
|
6
|
+
DIRTY=$(git status --porcelain 2>/dev/null | wc -l)
|
|
7
|
+
if [ "$DIRTY" -gt 0 ]; then
|
|
8
|
+
echo "BLOCKED: Worktree at $PATH_WT has $DIRTY uncommitted change(s)." >&2
|
|
9
|
+
echo "Commit or stash changes before removing." >&2
|
|
10
|
+
exit 2
|
|
11
|
+
fi
|
|
12
|
+
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
13
|
+
if [ -n "$BRANCH" ]; then
|
|
14
|
+
UNPUSHED=$(git log --oneline "origin/$BRANCH..$BRANCH" 2>/dev/null | wc -l)
|
|
15
|
+
if [ "$UNPUSHED" -gt 0 ]; then
|
|
16
|
+
echo "WARNING: $UNPUSHED unpushed commit(s) on $BRANCH." >&2
|
|
17
|
+
echo "Push before removing: git push origin $BRANCH" >&2
|
|
18
|
+
fi
|
|
19
|
+
fi
|
|
20
|
+
exit 0
|