cc-safe-setup 3.3.0 → 3.5.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/README.md +7 -1
- package/TROUBLESHOOTING.md +212 -0
- package/audit-web/index.html +15 -0
- package/docs/index.html +15 -0
- package/examples/commit-quality-gate.sh +89 -0
- package/examples/hook-debug-wrapper.sh +89 -0
- package/examples/loop-detector.sh +65 -0
- package/index.mjs +155 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**One command to make Claude Code safe for autonomous operation.**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
8 built-in hooks + 32 installable examples. Audit, create, lint, diff, watch, and learn. [Cheat Sheet](https://yurukusa.github.io/cc-safe-setup/cheatsheet.html) · [Web Tool](https://yurukusa.github.io/cc-safe-setup/) · [Troubleshooting](TROUBLESHOOTING.md)
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx cc-safe-setup
|
|
@@ -218,11 +218,17 @@ Or browse all available examples in [`examples/`](examples/):
|
|
|
218
218
|
- **tmp-cleanup.sh** — Clean up accumulated `/tmp/claude-*-cwd` files on session end ([#8856](https://github.com/anthropics/claude-code/issues/8856))
|
|
219
219
|
- **session-checkpoint.sh** — Save session state to mission file before context compaction ([#37866](https://github.com/anthropics/claude-code/issues/37866))
|
|
220
220
|
- **verify-before-commit.sh** — Block git commit when lint/test commands haven't been run ([#37818](https://github.com/anthropics/claude-code/issues/37818))
|
|
221
|
+
- **hook-debug-wrapper.sh** — Wrap any hook to log input/output/exit code/timing to `~/.claude/hook-debug.log`
|
|
222
|
+
- **loop-detector.sh** — Detect and break command repetition loops (warn at 3, block at 5 repeats)
|
|
221
223
|
|
|
222
224
|
## Safety Checklist
|
|
223
225
|
|
|
224
226
|
**[SAFETY_CHECKLIST.md](SAFETY_CHECKLIST.md)** — Copy-paste checklist for before/during/after autonomous sessions.
|
|
225
227
|
|
|
228
|
+
## Troubleshooting
|
|
229
|
+
|
|
230
|
+
**[TROUBLESHOOTING.md](TROUBLESHOOTING.md)** — "Hook doesn't work" → step-by-step diagnosis. Covers every common failure pattern.
|
|
231
|
+
|
|
226
232
|
## settings.json Reference
|
|
227
233
|
|
|
228
234
|
**[SETTINGS_REFERENCE.md](SETTINGS_REFERENCE.md)** — Complete reference for permissions, hooks, modes, and common configurations. Includes known limitations and workarounds.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Troubleshooting Claude Code Hooks
|
|
2
|
+
|
|
3
|
+
Your hook isn't working. Here's how to fix it, starting with the most common causes.
|
|
4
|
+
|
|
5
|
+
## Quick Diagnosis
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx cc-safe-setup --doctor
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This checks jq, settings.json, file permissions, shebangs, and common misconfigurations. If it says "All checks passed" but hooks still don't fire, read on.
|
|
12
|
+
|
|
13
|
+
## "Hook doesn't block anything"
|
|
14
|
+
|
|
15
|
+
### 1. Did you restart Claude Code?
|
|
16
|
+
|
|
17
|
+
Hooks are loaded on startup. After installing or modifying hooks, close Claude Code completely and reopen it.
|
|
18
|
+
|
|
19
|
+
### 2. Is the hook registered in settings.json?
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cat ~/.claude/settings.json | jq '.hooks'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
You should see your hook's path under the correct trigger. If not:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx cc-safe-setup # Re-registers all hooks
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 3. Is the hook file executable?
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
ls -la ~/.claude/hooks/your-hook.sh
|
|
35
|
+
# Should show -rwxr-xr-x
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Fix: `chmod +x ~/.claude/hooks/your-hook.sh`
|
|
39
|
+
|
|
40
|
+
### 4. Is jq installed?
|
|
41
|
+
|
|
42
|
+
Most hooks use jq to parse JSON input.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
jq --version
|
|
46
|
+
# Should print: jq-1.x
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Install: `brew install jq` (macOS) / `apt install jq` (Linux/WSL)
|
|
50
|
+
|
|
51
|
+
### 5. Does the hook work manually?
|
|
52
|
+
|
|
53
|
+
Test it outside Claude Code:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
echo '{"tool_input":{"command":"rm -rf /"}}' | bash ~/.claude/hooks/destructive-guard.sh
|
|
57
|
+
echo $?
|
|
58
|
+
# Should print: 2 (blocked)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
If exit code is 0, the hook isn't matching the pattern.
|
|
62
|
+
|
|
63
|
+
### 6. Wrong exit code
|
|
64
|
+
|
|
65
|
+
| Exit Code | Meaning |
|
|
66
|
+
|-----------|---------|
|
|
67
|
+
| **0** | Allow (or no opinion) |
|
|
68
|
+
| **2** | Block — the only code that stops execution |
|
|
69
|
+
| **1** | Error (treated as allow, not block!) |
|
|
70
|
+
|
|
71
|
+
Common mistake: using `exit 1` instead of `exit 2` to block. Only exit 2 blocks.
|
|
72
|
+
|
|
73
|
+
## "Hook blocks everything"
|
|
74
|
+
|
|
75
|
+
### 1. Overly broad grep pattern
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# BAD: matches ANY command containing "rm"
|
|
79
|
+
grep -q 'rm'
|
|
80
|
+
|
|
81
|
+
# GOOD: matches only rm with -rf flags
|
|
82
|
+
grep -qE 'rm\s+(-[rf]+\s+)*/'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 2. Missing empty-input guard
|
|
86
|
+
|
|
87
|
+
Every hook should handle empty input:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
91
|
+
[ -z "$COMMAND" ] && exit 0 # ← This line is critical
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Without this, the hook may exit 2 on tools that don't have `.tool_input.command` (like Read or Glob).
|
|
95
|
+
|
|
96
|
+
### 3. Wrong matcher
|
|
97
|
+
|
|
98
|
+
If your hook is for Bash commands but the matcher is empty, it runs on every tool call:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{"matcher": "Bash"} ← Correct: only Bash commands
|
|
102
|
+
{"matcher": ""} ← Runs on EVERY tool (Read, Edit, Glob, etc.)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## "Hook fires but doesn't auto-approve"
|
|
106
|
+
|
|
107
|
+
### 1. JSON output format is wrong
|
|
108
|
+
|
|
109
|
+
Auto-approve requires exact JSON structure:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"hookSpecificOutput": {
|
|
114
|
+
"hookEventName": "PreToolUse",
|
|
115
|
+
"permissionDecision": "allow",
|
|
116
|
+
"permissionDecisionReason": "your reason"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Missing any field = permission system ignores it.
|
|
122
|
+
|
|
123
|
+
### 2. jq output is going to stderr
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# BAD: output goes to stderr
|
|
127
|
+
jq -n '...' >&2
|
|
128
|
+
|
|
129
|
+
# GOOD: output goes to stdout
|
|
130
|
+
jq -n '...'
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Auto-approve JSON must go to stdout.
|
|
134
|
+
|
|
135
|
+
## "Permission prompts still appear for compound commands"
|
|
136
|
+
|
|
137
|
+
This is a known Claude Code limitation, not a hook issue. `Bash(git:*)` doesn't match `cd /path && git log`.
|
|
138
|
+
|
|
139
|
+
Fix:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npx cc-safe-setup --install-example compound-command-approver
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## "Hooks slow down Claude Code"
|
|
146
|
+
|
|
147
|
+
### 1. Check execution time
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npx cc-safe-setup --install-example hook-debug-wrapper
|
|
151
|
+
# Then wrap your slow hook to see timing
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Hooks should complete in <50ms. If a hook takes >200ms, it's noticeable.
|
|
155
|
+
|
|
156
|
+
### 2. Too many hooks on empty matcher
|
|
157
|
+
|
|
158
|
+
Hooks with `"matcher": ""` run on every single tool call. Move heavy checks to specific matchers:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{"matcher": "Bash"} ← Only when Bash runs
|
|
162
|
+
{"matcher": "Edit|Write"} ← Only when files are edited
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 3. Use --lint to find issues
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npx cc-safe-setup --lint
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Reports performance warnings and configuration issues.
|
|
172
|
+
|
|
173
|
+
## "Hooks work locally but not for teammates"
|
|
174
|
+
|
|
175
|
+
### 1. Compare settings
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npx cc-safe-setup --diff teammate-settings.json
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Shows exactly what's different between your setups.
|
|
182
|
+
|
|
183
|
+
### 2. Export and share
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npx cc-safe-setup --export # Creates cc-safe-setup-export.json
|
|
187
|
+
# Send to teammate
|
|
188
|
+
npx cc-safe-setup --import cc-safe-setup-export.json
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### 3. Different jq versions
|
|
192
|
+
|
|
193
|
+
Some hooks use jq features not available in older versions. Check: `jq --version`
|
|
194
|
+
|
|
195
|
+
## "Hooks run but don't log"
|
|
196
|
+
|
|
197
|
+
Hooks write to stderr for user-visible messages. For persistent logging:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
# Add to your hook
|
|
201
|
+
LOG="$HOME/.claude/blocked-commands.log"
|
|
202
|
+
echo "[$(date -Iseconds)] BLOCKED: reason | cmd: $COMMAND" >> "$LOG"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Then view with: `npx cc-safe-setup --watch` or `npx cc-safe-setup --stats`
|
|
206
|
+
|
|
207
|
+
## Still Stuck?
|
|
208
|
+
|
|
209
|
+
1. Wrap the hook with debug wrapper: `npx cc-safe-setup --install-example hook-debug-wrapper`
|
|
210
|
+
2. Check `~/.claude/hook-debug.log` for detailed I/O traces
|
|
211
|
+
3. Run `npx cc-safe-setup --doctor` for automated checks
|
|
212
|
+
4. Open an issue: [cc-safe-setup issues](https://github.com/yurukusa/cc-safe-setup/issues)
|
package/audit-web/index.html
CHANGED
|
@@ -598,6 +598,21 @@ exit 0`
|
|
|
598
598
|
|
|
599
599
|
return scripts[id] || '#!/bin/bash\nexit 0';
|
|
600
600
|
}
|
|
601
|
+
|
|
602
|
+
// Auto-load from URL parameter: ?config=base64encodedJSON
|
|
603
|
+
(function() {
|
|
604
|
+
const params = new URLSearchParams(window.location.search);
|
|
605
|
+
const config = params.get('config');
|
|
606
|
+
if (config) {
|
|
607
|
+
try {
|
|
608
|
+
const json = atob(config);
|
|
609
|
+
document.getElementById('settings').value = json;
|
|
610
|
+
runAudit();
|
|
611
|
+
} catch(e) {
|
|
612
|
+
console.error('Invalid config parameter');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
})();
|
|
601
616
|
</script>
|
|
602
617
|
</body>
|
|
603
618
|
</html>
|
package/docs/index.html
CHANGED
|
@@ -598,6 +598,21 @@ exit 0`
|
|
|
598
598
|
|
|
599
599
|
return scripts[id] || '#!/bin/bash\nexit 0';
|
|
600
600
|
}
|
|
601
|
+
|
|
602
|
+
// Auto-load from URL parameter: ?config=base64encodedJSON
|
|
603
|
+
(function() {
|
|
604
|
+
const params = new URLSearchParams(window.location.search);
|
|
605
|
+
const config = params.get('config');
|
|
606
|
+
if (config) {
|
|
607
|
+
try {
|
|
608
|
+
const json = atob(config);
|
|
609
|
+
document.getElementById('settings').value = json;
|
|
610
|
+
runAudit();
|
|
611
|
+
} catch(e) {
|
|
612
|
+
console.error('Invalid config parameter');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
})();
|
|
601
616
|
</script>
|
|
602
617
|
</body>
|
|
603
618
|
</html>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ================================================================
|
|
3
|
+
# commit-quality-gate.sh — Enforce commit message quality
|
|
4
|
+
# ================================================================
|
|
5
|
+
# PURPOSE:
|
|
6
|
+
# Claude Code generates commit messages that are often too long,
|
|
7
|
+
# too vague ("update code"), or contain the full diff summary.
|
|
8
|
+
# This hook enforces minimum quality standards.
|
|
9
|
+
#
|
|
10
|
+
# TRIGGER: PreToolUse
|
|
11
|
+
# MATCHER: "Bash"
|
|
12
|
+
#
|
|
13
|
+
# CHECKS:
|
|
14
|
+
# 1. Subject line length (max 72 chars, warn at 50)
|
|
15
|
+
# 2. No vague subjects ("update", "fix", "changes", "misc")
|
|
16
|
+
# 3. No mega-commits (subject line shouldn't list 5+ changes)
|
|
17
|
+
# 4. Body line length (max 72 chars per line)
|
|
18
|
+
# 5. No empty subject line
|
|
19
|
+
#
|
|
20
|
+
# CONFIGURATION:
|
|
21
|
+
# CC_COMMIT_MAX_SUBJECT=72
|
|
22
|
+
# CC_COMMIT_WARN_SUBJECT=50
|
|
23
|
+
# ================================================================
|
|
24
|
+
|
|
25
|
+
INPUT=$(cat)
|
|
26
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
27
|
+
|
|
28
|
+
if [[ -z "$COMMAND" ]]; then
|
|
29
|
+
exit 0
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Only check git commit commands
|
|
33
|
+
if ! echo "$COMMAND" | grep -qE '^\s*git\s+commit'; then
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Skip --amend (modifying existing) and --allow-empty
|
|
38
|
+
if echo "$COMMAND" | grep -qE '\-\-amend|\-\-allow-empty'; then
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Extract commit message
|
|
43
|
+
MSG=""
|
|
44
|
+
if echo "$COMMAND" | grep -qE '\-m\s'; then
|
|
45
|
+
# -m "message" or -m 'message'
|
|
46
|
+
MSG=$(echo "$COMMAND" | grep -oP "\-m\s+['\"]?\K[^'\"]+(?=['\"]?)" | head -1)
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
if [[ -z "$MSG" ]]; then
|
|
50
|
+
exit 0 # No inline message (might use editor)
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
MAX_SUBJECT="${CC_COMMIT_MAX_SUBJECT:-72}"
|
|
54
|
+
WARN_SUBJECT="${CC_COMMIT_WARN_SUBJECT:-50}"
|
|
55
|
+
|
|
56
|
+
# Get subject line (first line before any newline)
|
|
57
|
+
SUBJECT=$(echo "$MSG" | head -1)
|
|
58
|
+
SUBJECT_LEN=${#SUBJECT}
|
|
59
|
+
|
|
60
|
+
# Check 1: Empty subject
|
|
61
|
+
if [[ -z "$SUBJECT" ]] || [[ "$SUBJECT_LEN" -lt 3 ]]; then
|
|
62
|
+
echo "WARNING: Commit subject is empty or too short." >&2
|
|
63
|
+
exit 0 # Warn only
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# Check 2: Subject too long
|
|
67
|
+
if [[ "$SUBJECT_LEN" -gt "$MAX_SUBJECT" ]]; then
|
|
68
|
+
echo "WARNING: Commit subject is $SUBJECT_LEN chars (max $MAX_SUBJECT)." >&2
|
|
69
|
+
echo "Subject: $(echo "$SUBJECT" | head -c 80)..." >&2
|
|
70
|
+
echo "Tip: Keep the subject under $MAX_SUBJECT chars. Use the body for details." >&2
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# Check 3: Vague subjects
|
|
74
|
+
SUBJECT_LOWER=$(echo "$SUBJECT" | tr '[:upper:]' '[:lower:]')
|
|
75
|
+
VAGUE_PATTERNS="^(update|fix|change|misc|wip|tmp|test|stuff|things|minor|cleanup)$|^(update|fix|change)\s+(code|file|stuff|things)$"
|
|
76
|
+
if echo "$SUBJECT_LOWER" | grep -qiE "$VAGUE_PATTERNS"; then
|
|
77
|
+
echo "WARNING: Commit subject is too vague: \"$SUBJECT\"" >&2
|
|
78
|
+
echo "Be specific about what changed and why." >&2
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# Check 4: Mega-commit (too many changes listed)
|
|
82
|
+
COMMA_COUNT=$(echo "$SUBJECT" | grep -o ',' | wc -l)
|
|
83
|
+
AND_COUNT=$(echo "$SUBJECT_LOWER" | grep -o ' and ' | wc -l)
|
|
84
|
+
if [[ $((COMMA_COUNT + AND_COUNT)) -ge 4 ]]; then
|
|
85
|
+
echo "WARNING: Commit subject lists many changes. Consider splitting into smaller commits." >&2
|
|
86
|
+
echo "Subject: $(echo "$SUBJECT" | head -c 80)" >&2
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
exit 0
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ================================================================
|
|
3
|
+
# hook-debug-wrapper.sh — Debug wrapper for any Claude Code hook
|
|
4
|
+
# ================================================================
|
|
5
|
+
# PURPOSE:
|
|
6
|
+
# Wraps any existing hook script and logs its input, output,
|
|
7
|
+
# exit code, and execution time. Invaluable for debugging hooks
|
|
8
|
+
# that silently fail or produce unexpected results.
|
|
9
|
+
#
|
|
10
|
+
# USAGE:
|
|
11
|
+
# Instead of:
|
|
12
|
+
# "command": "~/.claude/hooks/destructive-guard.sh"
|
|
13
|
+
# Use:
|
|
14
|
+
# "command": "~/.claude/hooks/hook-debug-wrapper.sh ~/.claude/hooks/destructive-guard.sh"
|
|
15
|
+
#
|
|
16
|
+
# Or set CC_HOOK_DEBUG=1 to log all hooks (requires wrapper for each).
|
|
17
|
+
#
|
|
18
|
+
# WHAT IT LOGS:
|
|
19
|
+
# - Timestamp
|
|
20
|
+
# - Hook script path
|
|
21
|
+
# - Input JSON (truncated to 500 chars)
|
|
22
|
+
# - Exit code
|
|
23
|
+
# - stdout (truncated)
|
|
24
|
+
# - stderr (truncated)
|
|
25
|
+
# - Execution time in ms
|
|
26
|
+
#
|
|
27
|
+
# LOG LOCATION: ~/.claude/hook-debug.log
|
|
28
|
+
#
|
|
29
|
+
# TRIGGER: Any (wraps any hook)
|
|
30
|
+
# MATCHER: Any
|
|
31
|
+
# ================================================================
|
|
32
|
+
|
|
33
|
+
HOOK_SCRIPT="$1"
|
|
34
|
+
DEBUG_LOG="${CC_HOOK_DEBUG_LOG:-$HOME/.claude/hook-debug.log}"
|
|
35
|
+
|
|
36
|
+
if [[ -z "$HOOK_SCRIPT" ]] || [[ ! -f "$HOOK_SCRIPT" ]]; then
|
|
37
|
+
echo "Usage: hook-debug-wrapper.sh <hook-script>" >&2
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# Read input
|
|
42
|
+
INPUT=$(cat)
|
|
43
|
+
|
|
44
|
+
# Record start time
|
|
45
|
+
START_MS=$(($(date +%s%N) / 1000000))
|
|
46
|
+
|
|
47
|
+
# Run the actual hook, capturing all output
|
|
48
|
+
STDOUT_FILE=$(mktemp)
|
|
49
|
+
STDERR_FILE=$(mktemp)
|
|
50
|
+
echo "$INPUT" | bash "$HOOK_SCRIPT" > "$STDOUT_FILE" 2> "$STDERR_FILE"
|
|
51
|
+
EXIT_CODE=$?
|
|
52
|
+
|
|
53
|
+
END_MS=$(($(date +%s%N) / 1000000))
|
|
54
|
+
ELAPSED=$((END_MS - START_MS))
|
|
55
|
+
|
|
56
|
+
# Read outputs
|
|
57
|
+
STDOUT_CONTENT=$(cat "$STDOUT_FILE")
|
|
58
|
+
STDERR_CONTENT=$(cat "$STDERR_FILE")
|
|
59
|
+
rm -f "$STDOUT_FILE" "$STDERR_FILE"
|
|
60
|
+
|
|
61
|
+
# Log
|
|
62
|
+
HOOK_NAME=$(basename "$HOOK_SCRIPT")
|
|
63
|
+
INPUT_PREVIEW=$(echo "$INPUT" | head -c 500)
|
|
64
|
+
STDOUT_PREVIEW=$(echo "$STDOUT_CONTENT" | head -c 300)
|
|
65
|
+
STDERR_PREVIEW=$(echo "$STDERR_CONTENT" | head -c 300)
|
|
66
|
+
|
|
67
|
+
mkdir -p "$(dirname "$DEBUG_LOG")" 2>/dev/null
|
|
68
|
+
{
|
|
69
|
+
echo "=== $(date -Iseconds) === ${HOOK_NAME} ==="
|
|
70
|
+
echo "exit: ${EXIT_CODE} (${ELAPSED}ms)"
|
|
71
|
+
if [[ -n "$STDERR_PREVIEW" ]]; then
|
|
72
|
+
echo "stderr: ${STDERR_PREVIEW}"
|
|
73
|
+
fi
|
|
74
|
+
if [[ -n "$STDOUT_PREVIEW" ]]; then
|
|
75
|
+
echo "stdout: ${STDOUT_PREVIEW}"
|
|
76
|
+
fi
|
|
77
|
+
echo "input: ${INPUT_PREVIEW}"
|
|
78
|
+
echo ""
|
|
79
|
+
} >> "$DEBUG_LOG"
|
|
80
|
+
|
|
81
|
+
# Pass through the original output
|
|
82
|
+
if [[ -n "$STDOUT_CONTENT" ]]; then
|
|
83
|
+
echo "$STDOUT_CONTENT"
|
|
84
|
+
fi
|
|
85
|
+
if [[ -n "$STDERR_CONTENT" ]]; then
|
|
86
|
+
echo "$STDERR_CONTENT" >&2
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
exit "$EXIT_CODE"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ================================================================
|
|
3
|
+
# loop-detector.sh — Detect and break command repetition loops
|
|
4
|
+
# ================================================================
|
|
5
|
+
# PURPOSE:
|
|
6
|
+
# Claude Code sometimes gets stuck repeating the same command
|
|
7
|
+
# or cycle of commands. This hook detects repetition and warns
|
|
8
|
+
# before the loop wastes context and time.
|
|
9
|
+
#
|
|
10
|
+
# HOW IT WORKS:
|
|
11
|
+
# 1. Records last N commands in a state file
|
|
12
|
+
# 2. Checks if the current command matches recent commands
|
|
13
|
+
# 3. If same command appears 3+ times in last 5 calls → warn
|
|
14
|
+
# 4. If same command appears 5+ times → block
|
|
15
|
+
#
|
|
16
|
+
# TRIGGER: PreToolUse
|
|
17
|
+
# MATCHER: "Bash"
|
|
18
|
+
#
|
|
19
|
+
# CONFIGURATION:
|
|
20
|
+
# CC_LOOP_WARN=3 — warn after this many repeats (default: 3)
|
|
21
|
+
# CC_LOOP_BLOCK=5 — block after this many repeats (default: 5)
|
|
22
|
+
# CC_LOOP_WINDOW=10 — number of recent commands to track (default: 10)
|
|
23
|
+
# ================================================================
|
|
24
|
+
|
|
25
|
+
INPUT=$(cat)
|
|
26
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
27
|
+
|
|
28
|
+
if [[ -z "$COMMAND" ]]; then
|
|
29
|
+
exit 0
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
STATE_FILE="/tmp/cc-loop-detector-history"
|
|
33
|
+
WARN_THRESHOLD="${CC_LOOP_WARN:-3}"
|
|
34
|
+
BLOCK_THRESHOLD="${CC_LOOP_BLOCK:-5}"
|
|
35
|
+
WINDOW="${CC_LOOP_WINDOW:-10}"
|
|
36
|
+
|
|
37
|
+
# Normalize command (strip whitespace variations)
|
|
38
|
+
NORMALIZED=$(echo "$COMMAND" | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//')
|
|
39
|
+
|
|
40
|
+
# Record command
|
|
41
|
+
echo "$NORMALIZED" >> "$STATE_FILE"
|
|
42
|
+
|
|
43
|
+
# Keep only last N entries
|
|
44
|
+
if [ -f "$STATE_FILE" ]; then
|
|
45
|
+
tail -n "$WINDOW" "$STATE_FILE" > "${STATE_FILE}.tmp"
|
|
46
|
+
mv "${STATE_FILE}.tmp" "$STATE_FILE"
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# Count occurrences of current command in history
|
|
50
|
+
COUNT=$(grep -cF "$NORMALIZED" "$STATE_FILE" 2>/dev/null || echo 0)
|
|
51
|
+
|
|
52
|
+
if [ "$COUNT" -ge "$BLOCK_THRESHOLD" ]; then
|
|
53
|
+
echo "BLOCKED: Command repeated $COUNT times in last $WINDOW calls." >&2
|
|
54
|
+
echo "" >&2
|
|
55
|
+
echo "Command: $COMMAND" >&2
|
|
56
|
+
echo "" >&2
|
|
57
|
+
echo "This looks like an infinite loop. Try a different approach." >&2
|
|
58
|
+
echo "To reset: rm /tmp/cc-loop-detector-history" >&2
|
|
59
|
+
exit 2
|
|
60
|
+
elif [ "$COUNT" -ge "$WARN_THRESHOLD" ]; then
|
|
61
|
+
echo "WARNING: Command repeated $COUNT times. Possible loop." >&2
|
|
62
|
+
echo "Command: $(echo "$COMMAND" | head -c 100)" >&2
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
exit 0
|
package/index.mjs
CHANGED
|
@@ -80,6 +80,9 @@ const IMPORT_FILE = IMPORT_IDX !== -1 ? process.argv[IMPORT_IDX + 1] : null;
|
|
|
80
80
|
const STATS = process.argv.includes('--stats');
|
|
81
81
|
const JSON_OUTPUT = process.argv.includes('--json');
|
|
82
82
|
const LINT = process.argv.includes('--lint');
|
|
83
|
+
const DIFF_IDX = process.argv.findIndex(a => a === '--diff');
|
|
84
|
+
const DIFF_FILE = DIFF_IDX !== -1 ? process.argv[DIFF_IDX + 1] : null;
|
|
85
|
+
const SHARE = process.argv.includes('--share');
|
|
83
86
|
const CREATE_IDX = process.argv.findIndex(a => a === '--create');
|
|
84
87
|
const CREATE_DESC = CREATE_IDX !== -1 ? process.argv.slice(CREATE_IDX + 1).join(' ') : null;
|
|
85
88
|
|
|
@@ -101,6 +104,8 @@ if (HELP) {
|
|
|
101
104
|
npx cc-safe-setup --audit --json Machine-readable output for CI/CD
|
|
102
105
|
npx cc-safe-setup --scan Detect tech stack, recommend hooks
|
|
103
106
|
npx cc-safe-setup --learn Learn from your block history
|
|
107
|
+
npx cc-safe-setup --share Generate shareable URL for your setup
|
|
108
|
+
npx cc-safe-setup --diff <file> Compare your settings with another file
|
|
104
109
|
npx cc-safe-setup --lint Static analysis of hook configuration
|
|
105
110
|
npx cc-safe-setup --doctor Diagnose why hooks aren't working
|
|
106
111
|
npx cc-safe-setup --watch Live dashboard of blocked commands
|
|
@@ -316,6 +321,8 @@ function examples() {
|
|
|
316
321
|
'test-before-push.sh': 'Block git push when tests have not passed',
|
|
317
322
|
'timeout-guard.sh': 'Warn before long-running commands (servers, watchers)',
|
|
318
323
|
'git-config-guard.sh': 'Block git config --global modifications',
|
|
324
|
+
'case-sensitive-guard.sh': 'Detect case-insensitive FS collisions (exFAT/NTFS/HFS+)',
|
|
325
|
+
'compound-command-approver.sh': 'Auto-approve safe compound commands (cd && git log)',
|
|
319
326
|
},
|
|
320
327
|
'Auto-Approve': {
|
|
321
328
|
'auto-approve-build.sh': 'Auto-approve npm/yarn/cargo/go build, test, lint',
|
|
@@ -336,15 +343,19 @@ function examples() {
|
|
|
336
343
|
'Recovery': {
|
|
337
344
|
'auto-checkpoint.sh': 'Auto-commit after edits for rollback protection',
|
|
338
345
|
'auto-snapshot.sh': 'Auto-save file snapshots before edits (rollback protection)',
|
|
346
|
+
'session-checkpoint.sh': 'Save session state before context compaction',
|
|
339
347
|
},
|
|
340
348
|
'UX': {
|
|
341
349
|
'notify-waiting.sh': 'Desktop notification when Claude waits for input',
|
|
350
|
+
'tmp-cleanup.sh': 'Clean up /tmp/claude-*-cwd files on session end',
|
|
351
|
+
'hook-debug-wrapper.sh': 'Wrap any hook to log input/output/exit/timing',
|
|
352
|
+
'loop-detector.sh': 'Detect and break command repetition loops',
|
|
342
353
|
},
|
|
343
354
|
};
|
|
344
355
|
|
|
345
356
|
console.log();
|
|
346
357
|
console.log(c.bold + ' cc-safe-setup --examples' + c.reset);
|
|
347
|
-
console.log(c.dim + '
|
|
358
|
+
console.log(c.dim + ' 30 hooks beyond the 8 built-in ones' + c.reset);
|
|
348
359
|
console.log();
|
|
349
360
|
|
|
350
361
|
for (const [cat, hooks] of Object.entries(CATEGORIES)) {
|
|
@@ -769,6 +780,147 @@ async function fullSetup() {
|
|
|
769
780
|
console.log();
|
|
770
781
|
}
|
|
771
782
|
|
|
783
|
+
function share() {
|
|
784
|
+
console.log();
|
|
785
|
+
console.log(c.bold + ' cc-safe-setup --share' + c.reset);
|
|
786
|
+
console.log();
|
|
787
|
+
|
|
788
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
789
|
+
console.log(c.red + ' No settings.json found.' + c.reset);
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
794
|
+
|
|
795
|
+
// Strip sensitive data — only keep hooks and permissions structure
|
|
796
|
+
const shareable = {
|
|
797
|
+
hooks: settings.hooks || {},
|
|
798
|
+
permissions: settings.permissions || {},
|
|
799
|
+
defaultMode: settings.defaultMode,
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
// Remove full file paths, keep only script names
|
|
803
|
+
for (const trigger of Object.keys(shareable.hooks)) {
|
|
804
|
+
for (const entry of shareable.hooks[trigger]) {
|
|
805
|
+
for (const h of (entry.hooks || [])) {
|
|
806
|
+
if (h.command) {
|
|
807
|
+
// Keep only the filename
|
|
808
|
+
h.command = h.command.split('/').pop();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const json = JSON.stringify(shareable);
|
|
815
|
+
const encoded = Buffer.from(json).toString('base64');
|
|
816
|
+
const url = 'https://yurukusa.github.io/cc-safe-setup/?config=' + encoded;
|
|
817
|
+
|
|
818
|
+
console.log(c.green + ' Shareable URL:' + c.reset);
|
|
819
|
+
console.log();
|
|
820
|
+
console.log(' ' + url);
|
|
821
|
+
console.log();
|
|
822
|
+
console.log(c.dim + ' Anyone with this URL can audit your hook setup in their browser.' + c.reset);
|
|
823
|
+
console.log(c.dim + ' Only hook names and permissions are shared (no file paths or secrets).' + c.reset);
|
|
824
|
+
console.log();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function diff(otherFile) {
|
|
828
|
+
console.log();
|
|
829
|
+
console.log(c.bold + ' cc-safe-setup --diff' + c.reset);
|
|
830
|
+
console.log();
|
|
831
|
+
|
|
832
|
+
if (!existsSync(otherFile)) {
|
|
833
|
+
console.log(c.red + ' File not found: ' + otherFile + c.reset);
|
|
834
|
+
process.exit(1);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
838
|
+
console.log(c.red + ' No local settings.json found.' + c.reset);
|
|
839
|
+
process.exit(1);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
let local, other;
|
|
843
|
+
try { local = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch { console.log(c.red + ' Cannot parse local settings.json' + c.reset); process.exit(1); }
|
|
844
|
+
try { other = JSON.parse(readFileSync(otherFile, 'utf-8')); } catch { console.log(c.red + ' Cannot parse ' + otherFile + c.reset); process.exit(1); }
|
|
845
|
+
|
|
846
|
+
const getHookCommands = (settings, trigger) => {
|
|
847
|
+
return (settings.hooks?.[trigger] || []).flatMap(e => (e.hooks || []).map(h => {
|
|
848
|
+
const cmd = h.command || '';
|
|
849
|
+
return cmd.split('/').pop().replace(/\.sh$|\.js$|\.py$/, '');
|
|
850
|
+
}));
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
const getAllow = (settings) => settings.permissions?.allow || [];
|
|
854
|
+
const getDeny = (settings) => settings.permissions?.deny || [];
|
|
855
|
+
|
|
856
|
+
console.log(c.dim + ' Local: ' + SETTINGS_PATH + c.reset);
|
|
857
|
+
console.log(c.dim + ' Other: ' + otherFile + c.reset);
|
|
858
|
+
console.log();
|
|
859
|
+
|
|
860
|
+
// Compare hooks
|
|
861
|
+
const triggers = [...new Set([...Object.keys(local.hooks || {}), ...Object.keys(other.hooks || {})])];
|
|
862
|
+
|
|
863
|
+
let diffs = 0;
|
|
864
|
+
for (const trigger of triggers) {
|
|
865
|
+
const localHooks = new Set(getHookCommands(local, trigger));
|
|
866
|
+
const otherHooks = new Set(getHookCommands(other, trigger));
|
|
867
|
+
|
|
868
|
+
const onlyLocal = [...localHooks].filter(h => !otherHooks.has(h));
|
|
869
|
+
const onlyOther = [...otherHooks].filter(h => !localHooks.has(h));
|
|
870
|
+
const both = [...localHooks].filter(h => otherHooks.has(h));
|
|
871
|
+
|
|
872
|
+
if (onlyLocal.length > 0 || onlyOther.length > 0) {
|
|
873
|
+
console.log(c.bold + ' ' + trigger + c.reset);
|
|
874
|
+
for (const h of both) console.log(c.dim + ' = ' + h + c.reset);
|
|
875
|
+
for (const h of onlyLocal) { console.log(c.green + ' + ' + h + ' (local only)' + c.reset); diffs++; }
|
|
876
|
+
for (const h of onlyOther) { console.log(c.red + ' - ' + h + ' (other only)' + c.reset); diffs++; }
|
|
877
|
+
console.log();
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Compare permissions
|
|
882
|
+
const localAllow = getAllow(local);
|
|
883
|
+
const otherAllow = getAllow(other);
|
|
884
|
+
const onlyLocalAllow = localAllow.filter(a => !otherAllow.includes(a));
|
|
885
|
+
const onlyOtherAllow = otherAllow.filter(a => !localAllow.includes(a));
|
|
886
|
+
|
|
887
|
+
if (onlyLocalAllow.length > 0 || onlyOtherAllow.length > 0) {
|
|
888
|
+
console.log(c.bold + ' permissions.allow' + c.reset);
|
|
889
|
+
for (const a of onlyLocalAllow) { console.log(c.green + ' + ' + a + ' (local only)' + c.reset); diffs++; }
|
|
890
|
+
for (const a of onlyOtherAllow) { console.log(c.red + ' - ' + a + ' (other only)' + c.reset); diffs++; }
|
|
891
|
+
console.log();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Compare deny
|
|
895
|
+
const localDeny = getDeny(local);
|
|
896
|
+
const otherDeny = getDeny(other);
|
|
897
|
+
const onlyLocalDeny = localDeny.filter(a => !otherDeny.includes(a));
|
|
898
|
+
const onlyOtherDeny = otherDeny.filter(a => !localDeny.includes(a));
|
|
899
|
+
|
|
900
|
+
if (onlyLocalDeny.length > 0 || onlyOtherDeny.length > 0) {
|
|
901
|
+
console.log(c.bold + ' permissions.deny' + c.reset);
|
|
902
|
+
for (const d of onlyLocalDeny) { console.log(c.green + ' + ' + d + ' (local only)' + c.reset); diffs++; }
|
|
903
|
+
for (const d of onlyOtherDeny) { console.log(c.red + ' - ' + d + ' (other only)' + c.reset); diffs++; }
|
|
904
|
+
console.log();
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Compare mode
|
|
908
|
+
if ((local.defaultMode || 'default') !== (other.defaultMode || 'default')) {
|
|
909
|
+
console.log(c.bold + ' defaultMode' + c.reset);
|
|
910
|
+
console.log(c.green + ' local: ' + (local.defaultMode || 'default') + c.reset);
|
|
911
|
+
console.log(c.red + ' other: ' + (other.defaultMode || 'default') + c.reset);
|
|
912
|
+
console.log();
|
|
913
|
+
diffs++;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (diffs === 0) {
|
|
917
|
+
console.log(c.green + ' No differences found.' + c.reset);
|
|
918
|
+
} else {
|
|
919
|
+
console.log(c.dim + ' ' + diffs + ' difference(s) found.' + c.reset);
|
|
920
|
+
}
|
|
921
|
+
console.log();
|
|
922
|
+
}
|
|
923
|
+
|
|
772
924
|
async function lint() {
|
|
773
925
|
console.log();
|
|
774
926
|
console.log(c.bold + ' cc-safe-setup --lint' + c.reset);
|
|
@@ -1832,6 +1984,8 @@ async function main() {
|
|
|
1832
1984
|
if (FULL) return fullSetup();
|
|
1833
1985
|
if (DOCTOR) return doctor();
|
|
1834
1986
|
if (WATCH) return watch();
|
|
1987
|
+
if (SHARE) return share();
|
|
1988
|
+
if (DIFF_FILE) return diff(DIFF_FILE);
|
|
1835
1989
|
if (LINT) return lint();
|
|
1836
1990
|
if (CREATE_DESC) return createHook(CREATE_DESC);
|
|
1837
1991
|
if (STATS) return stats();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-safe-setup",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "One command to make Claude Code safe for autonomous operation. 8 built-in hooks +
|
|
3
|
+
"version": "3.5.0",
|
|
4
|
+
"description": "One command to make Claude Code safe for autonomous operation. 8 built-in hooks + 33 examples. Create, audit, lint, diff, share, watch, learn. 2,500+ daily npm downloads.",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cc-safe-setup": "index.mjs"
|