cc-safe-setup 29.7.0 → 29.8.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 +4 -4
- package/examples/compact-circuit-breaker.sh +72 -0
- package/examples/exploration-budget-guard.sh +77 -0
- package/examples/thinking-stall-detector.sh +61 -0
- package/package.json +2 -2
- package/tests/test-compact-circuit-breaker.sh +134 -0
- package/tests/test-exploration-budget-guard.sh +164 -0
- package/tests/test-thinking-stall-detector.sh +151 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
> 🚀 **Launching on [Product Hunt](https://www.producthunt.com/products/cc-safe-setup) — April 21!** Follow us and upvote to support open source safety for AI coding agents.
|
|
8
8
|
|
|
9
|
-
**One command to make Claude Code safe for autonomous operation.**
|
|
9
|
+
**One command to make Claude Code safe for autonomous operation.** 701 example hooks · 9,200+ tests · 30K+ total installs · [日本語](docs/README.ja.md)
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx cc-safe-setup
|
|
@@ -16,7 +16,7 @@ Installs 8 safety hooks in ~10 seconds. Blocks `rm -rf /`, prevents pushes to ma
|
|
|
16
16
|
|
|
17
17
|
> **What's a hook?** A checkpoint that runs before Claude executes a command. Like airport security — it inspects what's about to happen and blocks anything dangerous before it reaches the gate.
|
|
18
18
|
|
|
19
|
-
[**Getting Started**](https://yurukusa.github.io/cc-safe-setup/getting-started.html) · [**Hook Selector**](https://yurukusa.github.io/cc-safe-setup/hook-selector.html) · [**Token Checkup**](https://yurukusa.github.io/cc-safe-setup/token-checkup.html) · [**Cache Health**](https://yurukusa.github.io/cc-safe-setup/cache-health.html) · [**Version Check**](https://yurukusa.github.io/cc-safe-setup/version-check.html) · [**CLAUDE.md Analyzer**](https://yurukusa.github.io/cc-safe-setup/claudemd-analyzer.html) · [**All Tools**](https://yurukusa.github.io/cc-safe-setup/hub.html) · [**Recipes**](https://yurukusa.github.io/cc-safe-setup/recipes.html) · [Validate your settings.json](https://yurukusa.github.io/cc-safe-setup/validator.html) · [**Check your score**](https://yurukusa.github.io/cc-health-check/) (`npx cc-health-check`) · [**Safety Audit**](https://yurukusa.github.io/cc-safe-setup/safety-audit.html)
|
|
19
|
+
[**Getting Started**](https://yurukusa.github.io/cc-safe-setup/getting-started.html) · [**Incident Tracker**](https://yurukusa.github.io/cc-safe-setup/incidents.html) · [**Hook Selector**](https://yurukusa.github.io/cc-safe-setup/hook-selector.html) · [**Token Checkup**](https://yurukusa.github.io/cc-safe-setup/token-checkup.html) · [**Cache Health**](https://yurukusa.github.io/cc-safe-setup/cache-health.html) · [**Version Check**](https://yurukusa.github.io/cc-safe-setup/version-check.html) · [**CLAUDE.md Analyzer**](https://yurukusa.github.io/cc-safe-setup/claudemd-analyzer.html) · [**All Tools**](https://yurukusa.github.io/cc-safe-setup/hub.html) · [**Recipes**](https://yurukusa.github.io/cc-safe-setup/recipes.html) · [Validate your settings.json](https://yurukusa.github.io/cc-safe-setup/validator.html) · [**Check your score**](https://yurukusa.github.io/cc-health-check/) (`npx cc-health-check`) · [**Safety Audit**](https://yurukusa.github.io/cc-safe-setup/safety-audit.html)
|
|
20
20
|
|
|
21
21
|
```
|
|
22
22
|
cc-safe-setup
|
|
@@ -190,7 +190,7 @@ Guards against issues that corrupt sessions or waste tokens silently.
|
|
|
190
190
|
| `--scan [--apply]` | Tech stack detection |
|
|
191
191
|
| `--export / --import` | Team config sharing |
|
|
192
192
|
| `--verify` | Test each hook |
|
|
193
|
-
| `--install-example <name>` | Install from
|
|
193
|
+
| `--install-example <name>` | Install from 701 examples |
|
|
194
194
|
| `--examples [filter]` | Browse examples by keyword |
|
|
195
195
|
| `--full` | All-in-one setup |
|
|
196
196
|
| `--status` | Check installed hooks |
|
|
@@ -495,7 +495,7 @@ See [Issue #1](https://github.com/yurukusa/cc-safe-setup/issues/1) for details.
|
|
|
495
495
|
|
|
496
496
|
## Learn More
|
|
497
497
|
|
|
498
|
-
- **[Opus 4.7 Survival Guide](https://yurukusa.github.io/cc-safe-setup/opus-47-survival-guide.html)** —
|
|
498
|
+
- **[Opus 4.7 Survival Guide](https://yurukusa.github.io/cc-safe-setup/opus-47-survival-guide.html)** — 61 known issues (76+ GitHub Issues + CVEs) with fixes: data loss, recursive spawn DoS, billing mismatch, subagent OOM, cache_read anomaly, allowedTools bypass, 1.7x token inflation, classifier failure, thinking summary bugs, 30-min stalls, enterprise hooks bypass, and more. [`npx cc-safe-setup --opus47`](#-opus-47-crisis-april-2026)
|
|
499
499
|
- **[Token Book (¥2,500)](https://zenn.dev/yurukusa/books/token-savings-guide)** — Cut token consumption in half. CLAUDE.md optimization, hook-based guards, context management, workflow design. 44,000 words with copy-paste templates. Intro + Ch.1 free. [Details](https://yurukusa.github.io/cc-safe-setup/token-book.html)
|
|
500
500
|
- **[Safety Guide (¥800)](https://zenn.dev/yurukusa/books/6076c23b1cb18b)** — Token consumption diagnosis, file loss prevention, autonomous operation safety. From 800+ hours of real incidents. [Chapter 3 free](https://zenn.dev/yurukusa/books/6076c23b1cb18b/viewer/3-code-quality)
|
|
501
501
|
- **[800 Hours Operation Record (¥800)](https://zenn.dev/yurukusa/books/3c3c3baee85f0a19)** — Non-engineer running Claude Code autonomously for 800 hours. Failures, recovery, revenue reality. [Chapter 2 free](https://zenn.dev/yurukusa/books/3c3c3baee85f0a19/viewer/2-first-failures)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ================================================================
|
|
3
|
+
# compact-circuit-breaker.sh — Prevent auto-compact death spirals
|
|
4
|
+
# ================================================================
|
|
5
|
+
# PURPOSE:
|
|
6
|
+
# Auto-compact can enter an infinite loop when FileHistory
|
|
7
|
+
# recovery is degraded — each compaction fails to restore
|
|
8
|
+
# continuity, triggering the next one immediately. Users have
|
|
9
|
+
# lost entire overnight token budgets to 15+ consecutive
|
|
10
|
+
# compactions with zero forward progress (#51088). One incident
|
|
11
|
+
# recorded 211 compactions in a single session (#24179).
|
|
12
|
+
#
|
|
13
|
+
# This hook acts as a circuit breaker: it allows normal
|
|
14
|
+
# compaction but blocks rapid-fire compaction that indicates
|
|
15
|
+
# a death spiral. After MAX_PER_HOUR compactions, further
|
|
16
|
+
# attempts are blocked until the window resets.
|
|
17
|
+
#
|
|
18
|
+
# TRIGGER: PreCompact
|
|
19
|
+
# MATCHER: (none — PreCompact has no matcher)
|
|
20
|
+
#
|
|
21
|
+
# DECISION: exit 0 = allow, exit 2 = block
|
|
22
|
+
#
|
|
23
|
+
# CONFIG:
|
|
24
|
+
# MAX_PER_HOUR — Maximum compactions allowed per hour (default: 3)
|
|
25
|
+
# MIN_INTERVAL — Minimum seconds between compactions (default: 120)
|
|
26
|
+
#
|
|
27
|
+
# See: https://github.com/anthropics/claude-code/issues/51088
|
|
28
|
+
# https://github.com/anthropics/claude-code/issues/24179
|
|
29
|
+
# ================================================================
|
|
30
|
+
|
|
31
|
+
MAX_PER_HOUR="${CC_COMPACT_MAX_PER_HOUR:-3}"
|
|
32
|
+
MIN_INTERVAL="${CC_COMPACT_MIN_INTERVAL:-120}"
|
|
33
|
+
STATE_DIR="/tmp/.cc-compact-circuit-breaker"
|
|
34
|
+
STATE_FILE="$STATE_DIR/compaction-log"
|
|
35
|
+
|
|
36
|
+
mkdir -p "$STATE_DIR"
|
|
37
|
+
touch "$STATE_FILE"
|
|
38
|
+
|
|
39
|
+
NOW=$(date +%s)
|
|
40
|
+
ONE_HOUR_AGO=$((NOW - 3600))
|
|
41
|
+
|
|
42
|
+
# Clean old entries (older than 1 hour)
|
|
43
|
+
if [ -f "$STATE_FILE" ]; then
|
|
44
|
+
awk -v cutoff="$ONE_HOUR_AGO" '$1 >= cutoff' "$STATE_FILE" > "$STATE_FILE.tmp"
|
|
45
|
+
mv "$STATE_FILE.tmp" "$STATE_FILE"
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Count compactions in the last hour
|
|
49
|
+
RECENT_COUNT=$(wc -l < "$STATE_FILE" | tr -d ' ')
|
|
50
|
+
|
|
51
|
+
# Check minimum interval since last compaction
|
|
52
|
+
LAST_TIME=0
|
|
53
|
+
if [ -s "$STATE_FILE" ]; then
|
|
54
|
+
LAST_TIME=$(tail -1 "$STATE_FILE")
|
|
55
|
+
fi
|
|
56
|
+
ELAPSED=$((NOW - LAST_TIME))
|
|
57
|
+
|
|
58
|
+
# Circuit breaker: block if too many compactions
|
|
59
|
+
if [ "$RECENT_COUNT" -ge "$MAX_PER_HOUR" ]; then
|
|
60
|
+
echo "CIRCUIT BREAKER: $RECENT_COUNT compactions in the last hour (max: $MAX_PER_HOUR). Possible death spiral detected. Start a fresh session instead of compacting." >&2
|
|
61
|
+
exit 2
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# Cooldown: block if too soon after last compaction
|
|
65
|
+
if [ "$ELAPSED" -lt "$MIN_INTERVAL" ] && [ "$LAST_TIME" -gt 0 ]; then
|
|
66
|
+
echo "COOLDOWN: Last compaction was ${ELAPSED}s ago (min interval: ${MIN_INTERVAL}s). Wait before compacting again." >&2
|
|
67
|
+
exit 2
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# Allow compaction and log it
|
|
71
|
+
echo "$NOW" >> "$STATE_FILE"
|
|
72
|
+
exit 0
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# exploration-budget-guard.sh — Stop excessive exploration from draining tokens
|
|
3
|
+
#
|
|
4
|
+
# Solves: #51054 — Claude wastes 20% of weekly allowance exploring files
|
|
5
|
+
# on a simple task instead of acting. Read/Glob/Grep loops with
|
|
6
|
+
# no Edit/Write progress.
|
|
7
|
+
#
|
|
8
|
+
# Tracks read-only tool calls (Read, Glob, Grep) per session. After 25
|
|
9
|
+
# consecutive reads without a write, warns. After 40, blocks.
|
|
10
|
+
# Resets when an Edit/Write/Bash(write) occurs.
|
|
11
|
+
#
|
|
12
|
+
# TRIGGER: PreToolUse MATCHER: "Read|Glob|Grep|Edit|Write"
|
|
13
|
+
|
|
14
|
+
INPUT=$(cat)
|
|
15
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
16
|
+
|
|
17
|
+
STATE_DIR="/tmp/.cc-exploration-budget"
|
|
18
|
+
mkdir -p "$STATE_DIR"
|
|
19
|
+
|
|
20
|
+
# Session-scoped state file (keyed by parent PID)
|
|
21
|
+
STATE_FILE="$STATE_DIR/session-$$"
|
|
22
|
+
# Fall back to a shared file if $$ changes between hook calls
|
|
23
|
+
STATE_FILE="$STATE_DIR/exploration-count"
|
|
24
|
+
|
|
25
|
+
NOW=$(date +%s)
|
|
26
|
+
|
|
27
|
+
case "$TOOL" in
|
|
28
|
+
Edit|Write)
|
|
29
|
+
# Write operation = progress. Reset exploration counter.
|
|
30
|
+
echo "0 $NOW" > "$STATE_FILE"
|
|
31
|
+
exit 0
|
|
32
|
+
;;
|
|
33
|
+
Read|Glob|Grep)
|
|
34
|
+
# Read operation = exploration. Increment counter.
|
|
35
|
+
;;
|
|
36
|
+
*)
|
|
37
|
+
exit 0
|
|
38
|
+
;;
|
|
39
|
+
esac
|
|
40
|
+
|
|
41
|
+
# Read current count
|
|
42
|
+
COUNT=0
|
|
43
|
+
LAST_TIME=0
|
|
44
|
+
if [ -f "$STATE_FILE" ]; then
|
|
45
|
+
read -r COUNT LAST_TIME < "$STATE_FILE" 2>/dev/null || true
|
|
46
|
+
# Reset if more than 10 minutes since last call (new task)
|
|
47
|
+
ELAPSED=$(( NOW - LAST_TIME ))
|
|
48
|
+
if [ "$ELAPSED" -gt 600 ]; then
|
|
49
|
+
COUNT=0
|
|
50
|
+
fi
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
COUNT=$(( COUNT + 1 ))
|
|
54
|
+
echo "$COUNT $NOW" > "$STATE_FILE"
|
|
55
|
+
|
|
56
|
+
WARN_THRESHOLD=25
|
|
57
|
+
BLOCK_THRESHOLD=40
|
|
58
|
+
|
|
59
|
+
if [ "$COUNT" -ge "$BLOCK_THRESHOLD" ]; then
|
|
60
|
+
echo "BLOCKED: $COUNT consecutive read operations without writing anything." >&2
|
|
61
|
+
echo " You're stuck in an exploration loop — this wastes tokens." >&2
|
|
62
|
+
echo " Take action NOW:" >&2
|
|
63
|
+
echo " 1. Write your solution based on what you've already read" >&2
|
|
64
|
+
echo " 2. If unsure, make a small change and test it" >&2
|
|
65
|
+
echo " 3. Ask the user for clarification instead of reading more files" >&2
|
|
66
|
+
echo "" >&2
|
|
67
|
+
echo " Exploration budget: $COUNT/$BLOCK_THRESHOLD (EXCEEDED)" >&2
|
|
68
|
+
exit 2
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
if [ "$COUNT" -ge "$WARN_THRESHOLD" ]; then
|
|
72
|
+
echo "WARNING: $COUNT consecutive reads without any write." >&2
|
|
73
|
+
echo " You may be over-exploring. Consider acting on what you know." >&2
|
|
74
|
+
echo " Budget: $COUNT/$BLOCK_THRESHOLD reads before block." >&2
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
exit 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# thinking-stall-detector.sh — Detect when Claude's thinking phase stalls
|
|
3
|
+
#
|
|
4
|
+
# Solves: #51092 — Sonnet 4.6 thinking ran for 25 minutes, consuming
|
|
5
|
+
# 16M+ tokens. User lost entire token allowance to a single
|
|
6
|
+
# reasoning phase that never produced output.
|
|
7
|
+
#
|
|
8
|
+
# HOW IT WORKS:
|
|
9
|
+
# Tracks time between consecutive tool calls. If the gap exceeds
|
|
10
|
+
# a threshold (default 5 minutes), it means Claude was "thinking"
|
|
11
|
+
# without taking any action — likely a reasoning stall.
|
|
12
|
+
#
|
|
13
|
+
# On detection, logs a warning with the stall duration and suggests
|
|
14
|
+
# the user interrupt with Ctrl+C.
|
|
15
|
+
#
|
|
16
|
+
# WHY THIS MATTERS:
|
|
17
|
+
# During thinking, tokens are consumed but no hooks fire. This hook
|
|
18
|
+
# fires on the NEXT tool call after the stall, so it can't prevent
|
|
19
|
+
# the stall itself — but it alerts the user that one occurred, so
|
|
20
|
+
# they can watch for it happening again and interrupt early.
|
|
21
|
+
#
|
|
22
|
+
# TRIGGER: PreToolUse MATCHER: ""
|
|
23
|
+
# Also works as: Notification (fires on status changes)
|
|
24
|
+
#
|
|
25
|
+
# CONFIGURATION:
|
|
26
|
+
# CC_STALL_WARN_SECS=300 warn after 5-minute gap (default)
|
|
27
|
+
# CC_STALL_LOG=/tmp/cc-thinking-stalls.log
|
|
28
|
+
|
|
29
|
+
INPUT=$(cat)
|
|
30
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
31
|
+
|
|
32
|
+
STATE_FILE="/tmp/cc-thinking-stall-last-call"
|
|
33
|
+
LOG_FILE="${CC_STALL_LOG:-/tmp/cc-thinking-stalls.log}"
|
|
34
|
+
WARN_SECS="${CC_STALL_WARN_SECS:-300}"
|
|
35
|
+
|
|
36
|
+
NOW=$(date +%s)
|
|
37
|
+
|
|
38
|
+
# Read last tool call timestamp
|
|
39
|
+
LAST=$(cat "$STATE_FILE" 2>/dev/null || echo "$NOW")
|
|
40
|
+
|
|
41
|
+
# Update timestamp
|
|
42
|
+
echo "$NOW" > "$STATE_FILE"
|
|
43
|
+
|
|
44
|
+
# Calculate gap
|
|
45
|
+
GAP=$((NOW - LAST))
|
|
46
|
+
|
|
47
|
+
if [ "$GAP" -ge "$WARN_SECS" ]; then
|
|
48
|
+
MINUTES=$((GAP / 60))
|
|
49
|
+
REMAINDER=$((GAP % 60))
|
|
50
|
+
|
|
51
|
+
# Log the stall
|
|
52
|
+
echo "$(date -Iseconds) STALL ${MINUTES}m${REMAINDER}s before tool=$TOOL" >> "$LOG_FILE"
|
|
53
|
+
|
|
54
|
+
# Warn the user
|
|
55
|
+
echo "⚠️ Thinking stall detected: ${MINUTES}m${REMAINDER}s with no tool activity." >&2
|
|
56
|
+
echo "This may indicate a reasoning loop consuming tokens silently." >&2
|
|
57
|
+
echo "If this happens again, press Ctrl+C to interrupt." >&2
|
|
58
|
+
echo "See #51092: 25-minute thinking stall consumed 16M tokens." >&2
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
exit 0
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-safe-setup",
|
|
3
|
-
"version": "29.
|
|
4
|
-
"description": "One command to make Claude Code safe.
|
|
3
|
+
"version": "29.8.0",
|
|
4
|
+
"description": "One command to make Claude Code safe. 701 example hooks + 8 built-in. 56 CLI commands. Token consumption diagnosis. Works with Auto Mode.",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cc-safe-setup": "index.mjs"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for compact-circuit-breaker.sh
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
HOOK="$(dirname "$0")/../examples/compact-circuit-breaker.sh"
|
|
6
|
+
STATE_DIR="/tmp/.cc-compact-circuit-breaker"
|
|
7
|
+
STATE_FILE="$STATE_DIR/compaction-log"
|
|
8
|
+
PASS=0; FAIL=0; TOTAL=0
|
|
9
|
+
|
|
10
|
+
run_test() {
|
|
11
|
+
local desc="$1"; shift
|
|
12
|
+
TOTAL=$((TOTAL + 1))
|
|
13
|
+
if "$@" 2>/dev/null; then
|
|
14
|
+
PASS=$((PASS + 1))
|
|
15
|
+
echo "✅ $desc"
|
|
16
|
+
else
|
|
17
|
+
local code=$?
|
|
18
|
+
if [ "$code" -eq 2 ]; then
|
|
19
|
+
# exit 2 = block, might be expected
|
|
20
|
+
FAIL=$((FAIL + 1))
|
|
21
|
+
echo "❌ $desc (exit $code)"
|
|
22
|
+
else
|
|
23
|
+
FAIL=$((FAIL + 1))
|
|
24
|
+
echo "❌ $desc (exit $code)"
|
|
25
|
+
fi
|
|
26
|
+
fi
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
run_test_blocked() {
|
|
30
|
+
local desc="$1"; shift
|
|
31
|
+
TOTAL=$((TOTAL + 1))
|
|
32
|
+
local code=0
|
|
33
|
+
"$@" 2>/dev/null || code=$?
|
|
34
|
+
if [ "$code" -eq 2 ]; then
|
|
35
|
+
PASS=$((PASS + 1))
|
|
36
|
+
echo "✅ $desc (correctly blocked)"
|
|
37
|
+
else
|
|
38
|
+
FAIL=$((FAIL + 1))
|
|
39
|
+
echo "❌ $desc (expected block, got exit $code)"
|
|
40
|
+
fi
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cleanup() {
|
|
44
|
+
rm -rf "$STATE_DIR"
|
|
45
|
+
mkdir -p "$STATE_DIR"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Test 1: First compaction should be allowed
|
|
49
|
+
cleanup
|
|
50
|
+
run_test "First compaction allowed" bash "$HOOK"
|
|
51
|
+
|
|
52
|
+
# Test 2: Second compaction within MIN_INTERVAL should be blocked (cooldown)
|
|
53
|
+
run_test_blocked "Cooldown blocks rapid compaction" bash "$HOOK"
|
|
54
|
+
|
|
55
|
+
# Test 3: After cooldown, compaction should be allowed
|
|
56
|
+
cleanup
|
|
57
|
+
echo "$(($(date +%s) - 200))" > "$STATE_FILE"
|
|
58
|
+
run_test "Compaction allowed after cooldown" bash "$HOOK"
|
|
59
|
+
|
|
60
|
+
# Test 4: Circuit breaker triggers after MAX_PER_HOUR
|
|
61
|
+
cleanup
|
|
62
|
+
NOW=$(date +%s)
|
|
63
|
+
for i in $(seq 1 3); do
|
|
64
|
+
echo "$((NOW - 300 + i * 10))" >> "$STATE_FILE"
|
|
65
|
+
done
|
|
66
|
+
run_test_blocked "Circuit breaker blocks after 3 compactions" bash "$HOOK"
|
|
67
|
+
|
|
68
|
+
# Test 5: Old entries are cleaned up
|
|
69
|
+
cleanup
|
|
70
|
+
ONE_HOUR_AGO=$(($(date +%s) - 3700))
|
|
71
|
+
for i in $(seq 1 5); do
|
|
72
|
+
echo "$((ONE_HOUR_AGO - i * 10))" >> "$STATE_FILE"
|
|
73
|
+
done
|
|
74
|
+
run_test "Old entries cleaned, compaction allowed" bash "$HOOK"
|
|
75
|
+
|
|
76
|
+
# Test 6: Custom MAX_PER_HOUR
|
|
77
|
+
cleanup
|
|
78
|
+
NOW=$(date +%s)
|
|
79
|
+
echo "$((NOW - 200))" > "$STATE_FILE"
|
|
80
|
+
run_test_blocked "Custom MAX_PER_HOUR=1 blocks second" env CC_COMPACT_MAX_PER_HOUR=1 bash "$HOOK"
|
|
81
|
+
|
|
82
|
+
# Test 7: Custom MIN_INTERVAL
|
|
83
|
+
cleanup
|
|
84
|
+
echo "$(date +%s)" > "$STATE_FILE"
|
|
85
|
+
run_test_blocked "Default MIN_INTERVAL blocks immediate retry" bash "$HOOK"
|
|
86
|
+
|
|
87
|
+
# Test 8: State directory created if missing
|
|
88
|
+
rm -rf "$STATE_DIR"
|
|
89
|
+
run_test "Creates state directory" bash "$HOOK"
|
|
90
|
+
[ -d "$STATE_DIR" ] && echo " ↳ State directory exists ✅" || echo " ↳ State directory missing ❌"
|
|
91
|
+
|
|
92
|
+
# Test 9: Empty state file handled
|
|
93
|
+
cleanup
|
|
94
|
+
mkdir -p "$STATE_DIR"
|
|
95
|
+
touch "$STATE_FILE"
|
|
96
|
+
run_test "Empty state file handled" bash "$HOOK"
|
|
97
|
+
|
|
98
|
+
# Test 10: Mixed old and new entries
|
|
99
|
+
cleanup
|
|
100
|
+
NOW=$(date +%s)
|
|
101
|
+
echo "$((NOW - 7200))" >> "$STATE_FILE" # 2 hours ago (old)
|
|
102
|
+
echo "$((NOW - 7100))" >> "$STATE_FILE" # old
|
|
103
|
+
echo "$((NOW - 200))" >> "$STATE_FILE" # recent (1)
|
|
104
|
+
run_test "Mixed entries: old cleaned, recent counted" bash "$HOOK"
|
|
105
|
+
|
|
106
|
+
# Test 11: Exactly at MAX_PER_HOUR boundary
|
|
107
|
+
cleanup
|
|
108
|
+
NOW=$(date +%s)
|
|
109
|
+
echo "$((NOW - 1800))" >> "$STATE_FILE"
|
|
110
|
+
echo "$((NOW - 900))" >> "$STATE_FILE"
|
|
111
|
+
echo "$((NOW - 200))" >> "$STATE_FILE"
|
|
112
|
+
run_test_blocked "Exactly at MAX=3 boundary blocked" bash "$HOOK"
|
|
113
|
+
|
|
114
|
+
# Test 12: Error message content
|
|
115
|
+
cleanup
|
|
116
|
+
NOW=$(date +%s)
|
|
117
|
+
for i in $(seq 1 3); do
|
|
118
|
+
echo "$((NOW - 300 + i * 10))" >> "$STATE_FILE"
|
|
119
|
+
done
|
|
120
|
+
OUTPUT=$(bash "$HOOK" 2>&1 || true)
|
|
121
|
+
TOTAL=$((TOTAL + 1))
|
|
122
|
+
if echo "$OUTPUT" | grep -q "CIRCUIT BREAKER"; then
|
|
123
|
+
PASS=$((PASS + 1))
|
|
124
|
+
echo "✅ Error message contains CIRCUIT BREAKER"
|
|
125
|
+
else
|
|
126
|
+
FAIL=$((FAIL + 1))
|
|
127
|
+
echo "❌ Error message missing CIRCUIT BREAKER: $OUTPUT"
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
cleanup
|
|
131
|
+
|
|
132
|
+
echo ""
|
|
133
|
+
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
|
|
134
|
+
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for exploration-budget-guard.sh
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
HOOK="$(dirname "$0")/../examples/exploration-budget-guard.sh"
|
|
6
|
+
PASS=0
|
|
7
|
+
FAIL=0
|
|
8
|
+
STATE_FILE="/tmp/.cc-exploration-budget/exploration-count"
|
|
9
|
+
|
|
10
|
+
setup() {
|
|
11
|
+
rm -f "$STATE_FILE"
|
|
12
|
+
mkdir -p /tmp/.cc-exploration-budget
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
run_hook() {
|
|
16
|
+
echo "$1" | bash "$HOOK" 2>&1 || true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
assert_pass() {
|
|
20
|
+
local desc="$1"
|
|
21
|
+
local input="$2"
|
|
22
|
+
output=$(echo "$input" | bash "$HOOK" 2>&1) && rc=0 || rc=$?
|
|
23
|
+
if [ "$rc" -eq 0 ] && ! echo "$output" | grep -q "WARNING\|BLOCKED"; then
|
|
24
|
+
PASS=$((PASS + 1))
|
|
25
|
+
echo " PASS: $desc"
|
|
26
|
+
else
|
|
27
|
+
FAIL=$((FAIL + 1))
|
|
28
|
+
echo " FAIL: $desc (expected clean pass, rc=$rc)"
|
|
29
|
+
echo " output: $output"
|
|
30
|
+
fi
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
assert_warn() {
|
|
34
|
+
local desc="$1"
|
|
35
|
+
local input="$2"
|
|
36
|
+
output=$(echo "$input" | bash "$HOOK" 2>&1) && rc=0 || rc=$?
|
|
37
|
+
if [ $rc -eq 0 ] && echo "$output" | grep -q "WARNING"; then
|
|
38
|
+
PASS=$((PASS + 1))
|
|
39
|
+
echo " PASS: $desc"
|
|
40
|
+
else
|
|
41
|
+
FAIL=$((FAIL + 1))
|
|
42
|
+
echo " FAIL: $desc (expected warning, rc=$rc)"
|
|
43
|
+
echo " output: $output"
|
|
44
|
+
fi
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
assert_block() {
|
|
48
|
+
local desc="$1"
|
|
49
|
+
local input="$2"
|
|
50
|
+
output=$(echo "$input" | bash "$HOOK" 2>&1) && rc=0 || rc=$?
|
|
51
|
+
if [ $rc -eq 2 ] && echo "$output" | grep -q "BLOCKED"; then
|
|
52
|
+
PASS=$((PASS + 1))
|
|
53
|
+
echo " PASS: $desc"
|
|
54
|
+
else
|
|
55
|
+
FAIL=$((FAIL + 1))
|
|
56
|
+
echo " FAIL: $desc (expected block, rc=$rc)"
|
|
57
|
+
echo " output: $output"
|
|
58
|
+
fi
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
READ_INPUT='{"tool_name":"Read","tool_input":{"file_path":"/tmp/test.txt"}}'
|
|
62
|
+
GLOB_INPUT='{"tool_name":"Glob","tool_input":{"pattern":"*.ts"}}'
|
|
63
|
+
GREP_INPUT='{"tool_name":"Grep","tool_input":{"pattern":"foo"}}'
|
|
64
|
+
EDIT_INPUT='{"tool_name":"Edit","tool_input":{"file_path":"/tmp/test.txt","old_string":"a","new_string":"b"}}'
|
|
65
|
+
WRITE_INPUT='{"tool_name":"Write","tool_input":{"file_path":"/tmp/test.txt","content":"hello"}}'
|
|
66
|
+
OTHER_INPUT='{"tool_name":"Bash","tool_input":{"command":"ls"}}'
|
|
67
|
+
|
|
68
|
+
echo "=== exploration-budget-guard tests ==="
|
|
69
|
+
|
|
70
|
+
# Test 1: Single read passes
|
|
71
|
+
setup
|
|
72
|
+
assert_pass "single Read passes" "$READ_INPUT"
|
|
73
|
+
|
|
74
|
+
# Test 2: Non-tracked tool passes
|
|
75
|
+
setup
|
|
76
|
+
assert_pass "Bash is not tracked" "$OTHER_INPUT"
|
|
77
|
+
|
|
78
|
+
# Test 3: Edit resets counter
|
|
79
|
+
setup
|
|
80
|
+
for i in $(seq 1 20); do
|
|
81
|
+
echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
82
|
+
done
|
|
83
|
+
echo "$EDIT_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
84
|
+
assert_pass "Read after Edit reset passes" "$READ_INPUT"
|
|
85
|
+
|
|
86
|
+
# Test 4: Write resets counter
|
|
87
|
+
setup
|
|
88
|
+
for i in $(seq 1 20); do
|
|
89
|
+
echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
90
|
+
done
|
|
91
|
+
echo "$WRITE_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
92
|
+
assert_pass "Read after Write reset passes" "$READ_INPUT"
|
|
93
|
+
|
|
94
|
+
# Test 5: Warning at threshold
|
|
95
|
+
setup
|
|
96
|
+
for i in $(seq 1 24); do
|
|
97
|
+
echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
98
|
+
done
|
|
99
|
+
assert_warn "warns at 25 reads" "$READ_INPUT"
|
|
100
|
+
|
|
101
|
+
# Test 6: Different read tools count together
|
|
102
|
+
setup
|
|
103
|
+
for i in $(seq 1 8); do
|
|
104
|
+
echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
105
|
+
echo "$GLOB_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
106
|
+
echo "$GREP_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
107
|
+
done
|
|
108
|
+
assert_warn "mixed read tools warn at 25" "$READ_INPUT"
|
|
109
|
+
|
|
110
|
+
# Test 7: Block at 40
|
|
111
|
+
setup
|
|
112
|
+
for i in $(seq 1 39); do
|
|
113
|
+
echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
114
|
+
done
|
|
115
|
+
assert_block "blocks at 40 reads" "$READ_INPUT"
|
|
116
|
+
|
|
117
|
+
# Test 8: Block shows count
|
|
118
|
+
setup
|
|
119
|
+
for i in $(seq 1 41); do
|
|
120
|
+
echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
121
|
+
done
|
|
122
|
+
output=$(echo "$READ_INPUT" | bash "$HOOK" 2>&1 || true)
|
|
123
|
+
if echo "$output" | grep -q "EXCEEDED"; then
|
|
124
|
+
PASS=$((PASS + 1))
|
|
125
|
+
echo " PASS: block message shows EXCEEDED"
|
|
126
|
+
else
|
|
127
|
+
FAIL=$((FAIL + 1))
|
|
128
|
+
echo " FAIL: block message should show EXCEEDED"
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
# Test 9: Timeout reset (simulate 11 min gap)
|
|
132
|
+
setup
|
|
133
|
+
for i in $(seq 1 30); do
|
|
134
|
+
echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
135
|
+
done
|
|
136
|
+
# Fake old timestamp
|
|
137
|
+
echo "30 $(($(date +%s) - 700))" > "$STATE_FILE"
|
|
138
|
+
assert_pass "resets after 10min gap" "$READ_INPUT"
|
|
139
|
+
|
|
140
|
+
# Test 10: Glob passes normally
|
|
141
|
+
setup
|
|
142
|
+
assert_pass "single Glob passes" "$GLOB_INPUT"
|
|
143
|
+
|
|
144
|
+
# Test 11: Grep passes normally
|
|
145
|
+
setup
|
|
146
|
+
assert_pass "single Grep passes" "$GREP_INPUT"
|
|
147
|
+
|
|
148
|
+
# Test 12: Counter persists across different read tools
|
|
149
|
+
setup
|
|
150
|
+
for i in $(seq 1 10); do
|
|
151
|
+
echo "$READ_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
152
|
+
done
|
|
153
|
+
for i in $(seq 1 10); do
|
|
154
|
+
echo "$GLOB_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
155
|
+
done
|
|
156
|
+
for i in $(seq 1 5); do
|
|
157
|
+
echo "$GREP_INPUT" | bash "$HOOK" >/dev/null 2>&1 || true
|
|
158
|
+
done
|
|
159
|
+
# Count should be 25 now
|
|
160
|
+
assert_warn "warns at 25 mixed reads" "$GREP_INPUT"
|
|
161
|
+
|
|
162
|
+
echo ""
|
|
163
|
+
echo "Results: $PASS passed, $FAIL failed out of $((PASS + FAIL)) tests"
|
|
164
|
+
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for thinking-stall-detector.sh
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
HOOK="$(dirname "$0")/../examples/thinking-stall-detector.sh"
|
|
6
|
+
PASS=0
|
|
7
|
+
FAIL=0
|
|
8
|
+
STATE_FILE="/tmp/cc-thinking-stall-last-call"
|
|
9
|
+
LOG_FILE="/tmp/cc-thinking-stalls.log"
|
|
10
|
+
|
|
11
|
+
setup() {
|
|
12
|
+
rm -f "$STATE_FILE" "$LOG_FILE"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
run_hook() {
|
|
16
|
+
echo "$1" | CC_STALL_WARN_SECS="${2:-300}" bash "$HOOK" 2>&1 || true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# --- Test 1: First call (no previous state) should not warn ---
|
|
20
|
+
setup
|
|
21
|
+
output=$(echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>&1) || true
|
|
22
|
+
if echo "$output" | grep -q "stall"; then
|
|
23
|
+
echo " FAIL: first call should not warn"
|
|
24
|
+
FAIL=$((FAIL + 1))
|
|
25
|
+
else
|
|
26
|
+
echo " PASS: first call does not warn"
|
|
27
|
+
PASS=$((PASS + 1))
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# --- Test 2: Quick successive calls should not warn ---
|
|
31
|
+
setup
|
|
32
|
+
echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>/dev/null || true
|
|
33
|
+
sleep 1
|
|
34
|
+
output=$(echo '{"tool_name":"Write"}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>&1) || true
|
|
35
|
+
if echo "$output" | grep -q "stall"; then
|
|
36
|
+
echo " FAIL: quick succession should not warn"
|
|
37
|
+
FAIL=$((FAIL + 1))
|
|
38
|
+
else
|
|
39
|
+
echo " PASS: quick succession does not warn"
|
|
40
|
+
PASS=$((PASS + 1))
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# --- Test 3: Stall detected with low threshold ---
|
|
44
|
+
setup
|
|
45
|
+
echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
|
|
46
|
+
sleep 2
|
|
47
|
+
output=$(echo '{"tool_name":"Bash"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>&1) || true
|
|
48
|
+
if echo "$output" | grep -qi "stall"; then
|
|
49
|
+
echo " PASS: stall detected after threshold"
|
|
50
|
+
PASS=$((PASS + 1))
|
|
51
|
+
else
|
|
52
|
+
echo " FAIL: should have detected stall after 2s with 1s threshold"
|
|
53
|
+
echo " output: $output"
|
|
54
|
+
FAIL=$((FAIL + 1))
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# --- Test 4: Stall is logged to file ---
|
|
58
|
+
setup
|
|
59
|
+
echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
|
|
60
|
+
sleep 2
|
|
61
|
+
echo '{"tool_name":"Edit"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
|
|
62
|
+
if [ -f "$LOG_FILE" ] && grep -q "STALL" "$LOG_FILE"; then
|
|
63
|
+
echo " PASS: stall logged to file"
|
|
64
|
+
PASS=$((PASS + 1))
|
|
65
|
+
else
|
|
66
|
+
echo " FAIL: stall not logged to $LOG_FILE"
|
|
67
|
+
FAIL=$((FAIL + 1))
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# --- Test 5: Log contains tool name ---
|
|
71
|
+
if [ -f "$LOG_FILE" ] && grep -q "tool=Edit" "$LOG_FILE"; then
|
|
72
|
+
echo " PASS: log contains tool name"
|
|
73
|
+
PASS=$((PASS + 1))
|
|
74
|
+
else
|
|
75
|
+
echo " FAIL: log should contain tool=Edit"
|
|
76
|
+
FAIL=$((FAIL + 1))
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
# --- Test 6: Warning mentions issue number ---
|
|
80
|
+
setup
|
|
81
|
+
echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
|
|
82
|
+
sleep 2
|
|
83
|
+
output=$(echo '{"tool_name":"Glob"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>&1) || true
|
|
84
|
+
if echo "$output" | grep -q "51092"; then
|
|
85
|
+
echo " PASS: warning references #51092"
|
|
86
|
+
PASS=$((PASS + 1))
|
|
87
|
+
else
|
|
88
|
+
echo " FAIL: warning should reference #51092"
|
|
89
|
+
FAIL=$((FAIL + 1))
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
# --- Test 7: After stall, next quick call should not warn ---
|
|
93
|
+
setup
|
|
94
|
+
echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
|
|
95
|
+
sleep 2
|
|
96
|
+
echo '{"tool_name":"Edit"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
|
|
97
|
+
output=$(echo '{"tool_name":"Write"}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>&1) || true
|
|
98
|
+
if echo "$output" | grep -q "stall"; then
|
|
99
|
+
echo " FAIL: should not warn on quick follow-up after stall"
|
|
100
|
+
FAIL=$((FAIL + 1))
|
|
101
|
+
else
|
|
102
|
+
echo " PASS: no false positive after stall recovery"
|
|
103
|
+
PASS=$((PASS + 1))
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# --- Test 8: Hook always exits 0 (never blocks) ---
|
|
107
|
+
setup
|
|
108
|
+
echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null || true
|
|
109
|
+
sleep 2
|
|
110
|
+
echo '{"tool_name":"Bash"}' | CC_STALL_WARN_SECS=1 bash "$HOOK" 2>/dev/null
|
|
111
|
+
rc=$?
|
|
112
|
+
if [ "$rc" -eq 0 ]; then
|
|
113
|
+
echo " PASS: hook exits 0 (warn-only, never blocks)"
|
|
114
|
+
PASS=$((PASS + 1))
|
|
115
|
+
else
|
|
116
|
+
echo " FAIL: hook should always exit 0, got $rc"
|
|
117
|
+
FAIL=$((FAIL + 1))
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# --- Test 9: Empty tool name handled gracefully ---
|
|
121
|
+
setup
|
|
122
|
+
output=$(echo '{}' | CC_STALL_WARN_SECS=300 bash "$HOOK" 2>&1) || true
|
|
123
|
+
rc=$?
|
|
124
|
+
if [ "$rc" -eq 0 ]; then
|
|
125
|
+
echo " PASS: empty tool name handled"
|
|
126
|
+
PASS=$((PASS + 1))
|
|
127
|
+
else
|
|
128
|
+
echo " FAIL: should handle empty tool name gracefully"
|
|
129
|
+
FAIL=$((FAIL + 1))
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# --- Test 10: Custom log path ---
|
|
133
|
+
setup
|
|
134
|
+
CUSTOM_LOG="/tmp/cc-stall-custom-test.log"
|
|
135
|
+
rm -f "$CUSTOM_LOG"
|
|
136
|
+
echo '{"tool_name":"Read"}' | CC_STALL_WARN_SECS=1 CC_STALL_LOG="$CUSTOM_LOG" bash "$HOOK" 2>/dev/null || true
|
|
137
|
+
sleep 2
|
|
138
|
+
echo '{"tool_name":"Write"}' | CC_STALL_WARN_SECS=1 CC_STALL_LOG="$CUSTOM_LOG" bash "$HOOK" 2>/dev/null || true
|
|
139
|
+
if [ -f "$CUSTOM_LOG" ] && grep -q "STALL" "$CUSTOM_LOG"; then
|
|
140
|
+
echo " PASS: custom log path works"
|
|
141
|
+
PASS=$((PASS + 1))
|
|
142
|
+
else
|
|
143
|
+
echo " FAIL: custom log path not working"
|
|
144
|
+
FAIL=$((FAIL + 1))
|
|
145
|
+
fi
|
|
146
|
+
rm -f "$CUSTOM_LOG"
|
|
147
|
+
|
|
148
|
+
# --- Summary ---
|
|
149
|
+
echo ""
|
|
150
|
+
echo "Results: $PASS passed, $FAIL failed out of $((PASS + FAIL))"
|
|
151
|
+
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|