cc-safe-setup 28.3.5 → 28.4.1
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 +8 -1
- package/examples/auto-compact-prep.sh +31 -0
- package/examples/auto-git-checkpoint.sh +15 -0
- package/examples/edit-verify.sh +30 -0
- package/examples/mcp-tool-guard.sh +26 -0
- package/examples/prompt-injection-guard.sh +22 -0
- package/examples/session-state-saver.sh +30 -0
- package/examples/subagent-budget-guard.sh +34 -0
- package/index.mjs +593 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**One command to make Claude Code safe for autonomous operation.** [日本語](docs/README.ja.md)
|
|
8
8
|
|
|
9
|
-
8 built-in +
|
|
9
|
+
8 built-in + 319 examples = **327 hooks**. 45 CLI commands. 955 tests. 5 languages. [**Getting Started**](https://yurukusa.github.io/cc-safe-setup/getting-started.html) · [**Hub**](https://yurukusa.github.io/cc-safe-setup/hub.html) · [**Recipes**](https://yurukusa.github.io/cc-safe-setup/recipes.html) · [Wizard](https://yurukusa.github.io/cc-safe-setup/wizard.html) · [Cheat Sheet](https://yurukusa.github.io/cc-safe-setup/hooks-cheatsheet.html) · [Builder](https://yurukusa.github.io/cc-safe-setup/builder.html) · [FAQ](https://yurukusa.github.io/cc-safe-setup/faq.html) · [Examples](https://yurukusa.github.io/cc-safe-setup/by-example.html) · [Matrix](https://yurukusa.github.io/cc-safe-setup/matrix.html) · [Playground](https://yurukusa.github.io/cc-hook-registry/playground.html)
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx cc-safe-setup
|
|
@@ -110,6 +110,8 @@ Each hook exists because a real incident happened without it.
|
|
|
110
110
|
| `--init-project` | Full project setup (hooks + CLAUDE.md + CI) |
|
|
111
111
|
| `--score` | CI-friendly safety score (exit 1 if below threshold) |
|
|
112
112
|
| `--test-hook <name>` | Test a specific hook with sample input |
|
|
113
|
+
| `--simulate "cmd"` | Preview how all hooks react to a command |
|
|
114
|
+
| `--protect <path>` | Block edits to a file or directory |
|
|
113
115
|
| `--changelog` | Show what changed in each version |
|
|
114
116
|
| `--report` | Generate safety report |
|
|
115
117
|
| `--help` | Show help |
|
|
@@ -127,6 +129,11 @@ Each hook exists because a real incident happened without it.
|
|
|
127
129
|
| Choose a safety level | `npx cc-safe-setup --profile strict` |
|
|
128
130
|
| See what Claude blocked today | `npx cc-safe-setup --replay` |
|
|
129
131
|
| Know why a hook exists | `npx cc-safe-setup --why destructive-guard` |
|
|
132
|
+
| Block silent memory file edits | `npx cc-safe-setup --install-example memory-write-guard` |
|
|
133
|
+
| Stop built-in skills editing opaquely | `npx cc-safe-setup --install-example skill-gate` |
|
|
134
|
+
| Diagnose why hooks aren't working | `npx cc-safe-setup --doctor` |
|
|
135
|
+
| Preview how hooks react to a command | `npx cc-safe-setup --simulate "git push origin main"` |
|
|
136
|
+
| Protect a specific file from edits | `npx cc-safe-setup --protect .env` |
|
|
130
137
|
| Migrate from Cursor/Windsurf | [Migration Guide](https://yurukusa.github.io/cc-safe-setup/migration-guide.html) |
|
|
131
138
|
|
|
132
139
|
## How It Works
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
STATE_DIR="${HOME}/.claude"
|
|
3
|
+
COUNTER_FILE="${STATE_DIR}/session-call-count"
|
|
4
|
+
PREP_FLAG="${STATE_DIR}/compact-prep-done"
|
|
5
|
+
CHECKPOINT=".claude/pre-compact-checkpoint.md"
|
|
6
|
+
COUNT=0
|
|
7
|
+
[ -f "$COUNTER_FILE" ] && COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
|
|
8
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
9
|
+
COUNT=1
|
|
10
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
11
|
+
else
|
|
12
|
+
COUNT=$((COUNT + 1))
|
|
13
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
14
|
+
fi
|
|
15
|
+
THRESHOLD=${CC_COMPACT_PREP_THRESHOLD:-200}
|
|
16
|
+
if (( COUNT >= THRESHOLD )) && [ ! -f "$PREP_FLAG" ]; then
|
|
17
|
+
mkdir -p "$(dirname "$CHECKPOINT")" 2>/dev/null
|
|
18
|
+
BRANCH=$(git branch --show-current 2>/dev/null || echo "?")
|
|
19
|
+
DIRTY=$(git status --porcelain 2>/dev/null | wc -l)
|
|
20
|
+
LAST_5=$(git log --oneline -5 2>/dev/null)
|
|
21
|
+
cat > "$CHECKPOINT" << CKPT
|
|
22
|
+
Saved: $(date -Iseconds) | Tool call: #${COUNT}
|
|
23
|
+
Branch: ${BRANCH} | Dirty files: ${DIRTY}
|
|
24
|
+
${LAST_5}
|
|
25
|
+
Read this file to understand what you were working on before context was compacted.
|
|
26
|
+
Check git status and git log for current state. Continue from the last commit.
|
|
27
|
+
CKPT
|
|
28
|
+
touch "$PREP_FLAG"
|
|
29
|
+
echo "NOTICE: Context may compact soon (call #${COUNT}). Checkpoint saved to ${CHECKPOINT}" >&2
|
|
30
|
+
fi
|
|
31
|
+
exit 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
3
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
4
|
+
[[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]] && exit 0
|
|
5
|
+
[ -z "$FILE" ] && exit 0
|
|
6
|
+
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
7
|
+
[ ! -f "$FILE" ] && exit 0
|
|
8
|
+
CKPT_DIR=".claude/checkpoints"
|
|
9
|
+
mkdir -p "$CKPT_DIR" 2>/dev/null
|
|
10
|
+
BASENAME=$(basename "$FILE")
|
|
11
|
+
TIMESTAMP=$(date +%H%M%S)
|
|
12
|
+
CKPT_FILE="${CKPT_DIR}/${BASENAME}.${TIMESTAMP}.bak"
|
|
13
|
+
cp "$FILE" "$CKPT_FILE" 2>/dev/null
|
|
14
|
+
ls -t "${CKPT_DIR}/${BASENAME}".*.bak 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null
|
|
15
|
+
exit 0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
3
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
4
|
+
[[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]] && exit 0
|
|
5
|
+
[ -z "$FILE" ] && exit 0
|
|
6
|
+
[ ! -f "$FILE" ] && { echo "WARNING: File does not exist after edit: $FILE" >&2; exit 0; }
|
|
7
|
+
SIZE=$(wc -c < "$FILE" 2>/dev/null || echo 0)
|
|
8
|
+
if [ "$SIZE" -eq 0 ]; then
|
|
9
|
+
echo "WARNING: File is empty after edit: $FILE (possible truncation)" >&2
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
if [ "$TOOL" = "Edit" ]; then
|
|
13
|
+
NEW_STR=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty' 2>/dev/null)
|
|
14
|
+
if [ -n "$NEW_STR" ]; then
|
|
15
|
+
FIRST_LINE=$(echo "$NEW_STR" | head -1)
|
|
16
|
+
if [ -n "$FIRST_LINE" ] && ! grep -qF "$FIRST_LINE" "$FILE" 2>/dev/null; then
|
|
17
|
+
echo "WARNING: Edit may not have applied — new_string not found in $FILE" >&2
|
|
18
|
+
fi
|
|
19
|
+
fi
|
|
20
|
+
fi
|
|
21
|
+
if grep -qE '^(<<<<<<<|=======|>>>>>>>)' "$FILE" 2>/dev/null; then
|
|
22
|
+
echo "WARNING: Merge conflict markers detected in $FILE after edit" >&2
|
|
23
|
+
fi
|
|
24
|
+
if [ "$SIZE" -lt 10 ]; then
|
|
25
|
+
case "$FILE" in
|
|
26
|
+
*.json|*.yaml|*.yml|*.toml) ;; # Config files can be small
|
|
27
|
+
*) echo "WARNING: File suspiciously small ($SIZE bytes) after edit: $FILE" >&2 ;;
|
|
28
|
+
esac
|
|
29
|
+
fi
|
|
30
|
+
exit 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
3
|
+
echo "$TOOL" | grep -q '^mcp__' || exit 0
|
|
4
|
+
if [ "${CC_MCP_WARN_ALL:-0}" = "1" ]; then
|
|
5
|
+
echo "NOTE: MCP tool call: $TOOL" >&2
|
|
6
|
+
fi
|
|
7
|
+
BLOCKED="${CC_MCP_BLOCKED_TOOLS:-}"
|
|
8
|
+
if [ -n "$BLOCKED" ]; then
|
|
9
|
+
IFS=',' read -ra PATTERNS <<< "$BLOCKED"
|
|
10
|
+
for pattern in "${PATTERNS[@]}"; do
|
|
11
|
+
pattern=$(echo "$pattern" | xargs) # trim whitespace
|
|
12
|
+
if [[ "$TOOL" == *"$pattern"* ]]; then
|
|
13
|
+
echo "BLOCKED: MCP tool $TOOL matches blocked pattern: $pattern" >&2
|
|
14
|
+
exit 2
|
|
15
|
+
fi
|
|
16
|
+
done
|
|
17
|
+
fi
|
|
18
|
+
case "$TOOL" in
|
|
19
|
+
*delete*|*remove*|*drop*|*destroy*|*purge*)
|
|
20
|
+
echo "WARNING: Potentially destructive MCP tool: $TOOL" >&2
|
|
21
|
+
;;
|
|
22
|
+
*send_email*|*send_message*|*post*|*publish*)
|
|
23
|
+
echo "WARNING: MCP tool with external side effects: $TOOL" >&2
|
|
24
|
+
;;
|
|
25
|
+
esac
|
|
26
|
+
exit 0
|
|
@@ -44,6 +44,28 @@ if echo "$OUTPUT" | grep -qP '<!--.*(?:execute|run|delete|remove).*-->'; then
|
|
|
44
44
|
SUSPICIOUS=1
|
|
45
45
|
fi
|
|
46
46
|
|
|
47
|
+
# tool_runtime_configuration injection (GitHub #28586)
|
|
48
|
+
if echo "$OUTPUT" | grep -qiE '<tool_runtime_configuration>|</tool_runtime_configuration>'; then
|
|
49
|
+
echo "WARNING: tool_runtime_configuration injection detected — can disable tools" >&2
|
|
50
|
+
SUSPICIOUS=1
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# MCP server instruction override (GitHub #30545)
|
|
54
|
+
if echo "$OUTPUT" | grep -qiE 'override.*CLAUDE\.md|ignore.*project\s+rules|disregard.*instructions'; then
|
|
55
|
+
echo "WARNING: Possible MCP instruction override detected" >&2
|
|
56
|
+
SUSPICIOUS=1
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Base64-encoded commands (obfuscated injection)
|
|
60
|
+
if echo "$OUTPUT" | grep -qE '[A-Za-z0-9+/]{40,}={0,2}'; then
|
|
61
|
+
# Check if it decodes to something suspicious
|
|
62
|
+
DECODED=$(echo "$OUTPUT" | grep -oE '[A-Za-z0-9+/]{40,}={0,2}' | head -1 | base64 -d 2>/dev/null || true)
|
|
63
|
+
if echo "$DECODED" | grep -qiE 'rm\s+-rf|curl.*\|.*sh|eval|exec'; then
|
|
64
|
+
echo "WARNING: Base64-encoded command detected in tool output" >&2
|
|
65
|
+
SUSPICIOUS=1
|
|
66
|
+
fi
|
|
67
|
+
fi
|
|
68
|
+
|
|
47
69
|
if [ "$SUSPICIOUS" -eq 1 ]; then
|
|
48
70
|
echo "Review the output carefully before acting on it." >&2
|
|
49
71
|
fi
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
3
|
+
STATE_DIR="${HOME}/.claude"
|
|
4
|
+
COUNTER_FILE="${STATE_DIR}/session-call-count"
|
|
5
|
+
STATE_FILE=".claude/session-state.md"
|
|
6
|
+
SAVE_INTERVAL=${CC_STATE_SAVE_INTERVAL:-50}
|
|
7
|
+
mkdir -p "${STATE_DIR}" 2>/dev/null
|
|
8
|
+
mkdir -p "$(dirname "${STATE_FILE}")" 2>/dev/null
|
|
9
|
+
COUNT=0
|
|
10
|
+
[ -f "$COUNTER_FILE" ] && COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
|
|
11
|
+
COUNT=$((COUNT + 1))
|
|
12
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
13
|
+
if (( COUNT % SAVE_INTERVAL == 0 )); then
|
|
14
|
+
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
|
15
|
+
MODIFIED=$(git diff --name-only 2>/dev/null | head -10)
|
|
16
|
+
STAGED=$(git diff --cached --name-only 2>/dev/null | head -10)
|
|
17
|
+
RECENT_COMMITS=$(git log --oneline -5 2>/dev/null)
|
|
18
|
+
cat > "$STATE_FILE" << STATE
|
|
19
|
+
Updated: $(date -Iseconds)
|
|
20
|
+
${BRANCH}
|
|
21
|
+
${MODIFIED:-none}
|
|
22
|
+
${STAGED:-none}
|
|
23
|
+
${RECENT_COMMITS:-none}
|
|
24
|
+
${COUNT}
|
|
25
|
+
---
|
|
26
|
+
*Read this file after compaction to restore context.*
|
|
27
|
+
STATE
|
|
28
|
+
echo "Session state saved (call #${COUNT})" >&2
|
|
29
|
+
fi
|
|
30
|
+
exit 0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
3
|
+
[[ "$TOOL" != "Agent" ]] && exit 0
|
|
4
|
+
MAX_AGENTS=${CC_MAX_SUBAGENTS:-5}
|
|
5
|
+
TRACKER="${HOME}/.claude/active-agents"
|
|
6
|
+
mkdir -p "$(dirname "$TRACKER")" 2>/dev/null
|
|
7
|
+
NOW=$(date +%s)
|
|
8
|
+
ACTIVE=0
|
|
9
|
+
if [ -f "$TRACKER" ]; then
|
|
10
|
+
while IFS= read -r line; do
|
|
11
|
+
TS=$(echo "$line" | cut -d'|' -f1)
|
|
12
|
+
AGE=$(( NOW - TS ))
|
|
13
|
+
if (( AGE < 1800 )); then
|
|
14
|
+
ACTIVE=$((ACTIVE + 1))
|
|
15
|
+
fi
|
|
16
|
+
done < "$TRACKER"
|
|
17
|
+
fi
|
|
18
|
+
if (( ACTIVE >= MAX_AGENTS )); then
|
|
19
|
+
echo "BLOCKED: $ACTIVE active subagents (max: $MAX_AGENTS)." >&2
|
|
20
|
+
echo "Wait for existing agents to complete before spawning more." >&2
|
|
21
|
+
echo "Override: CC_MAX_SUBAGENTS=10" >&2
|
|
22
|
+
exit 2
|
|
23
|
+
fi
|
|
24
|
+
echo "${NOW}|agent" >> "$TRACKER"
|
|
25
|
+
if [ -f "$TRACKER" ]; then
|
|
26
|
+
TMP=$(mktemp)
|
|
27
|
+
while IFS= read -r line; do
|
|
28
|
+
TS=$(echo "$line" | cut -d'|' -f1)
|
|
29
|
+
AGE=$(( NOW - TS ))
|
|
30
|
+
(( AGE < 1800 )) && echo "$line"
|
|
31
|
+
done < "$TRACKER" > "$TMP"
|
|
32
|
+
mv "$TMP" "$TRACKER"
|
|
33
|
+
fi
|
|
34
|
+
exit 0
|
package/index.mjs
CHANGED
|
@@ -114,6 +114,15 @@ const SUGGEST = process.argv.includes('--suggest');
|
|
|
114
114
|
const INIT_PROJECT = process.argv.includes('--init-project');
|
|
115
115
|
const SCORE_ONLY = process.argv.includes('--score');
|
|
116
116
|
const CHANGELOG_CMD = process.argv.includes('--changelog');
|
|
117
|
+
const VALIDATE = process.argv.includes('--validate');
|
|
118
|
+
const SAFE_MODE = process.argv.includes('--safe-mode');
|
|
119
|
+
const SAFE_MODE_OFF = process.argv.includes('--safe-mode') && process.argv.includes('off');
|
|
120
|
+
const SIMULATE_IDX = process.argv.findIndex(a => a === '--simulate');
|
|
121
|
+
const SIMULATE_CMD = SIMULATE_IDX !== -1 ? process.argv.slice(SIMULATE_IDX + 1).join(' ') : null;
|
|
122
|
+
const PROTECT_IDX = process.argv.findIndex(a => a === '--protect');
|
|
123
|
+
const PROTECT_PATH = PROTECT_IDX !== -1 ? process.argv[PROTECT_IDX + 1] : null;
|
|
124
|
+
const RULES_IDX = process.argv.findIndex(a => a === '--rules');
|
|
125
|
+
const RULES_FILE = RULES_IDX !== -1 ? (process.argv[RULES_IDX + 1] || '.claude/rules.yaml') : null;
|
|
117
126
|
const TEST_HOOK_IDX = process.argv.findIndex(a => a === '--test-hook');
|
|
118
127
|
const TEST_HOOK = TEST_HOOK_IDX !== -1 ? process.argv[TEST_HOOK_IDX + 1] : null;
|
|
119
128
|
const WHY_IDX = process.argv.findIndex(a => a === '--why');
|
|
@@ -147,6 +156,9 @@ if (HELP) {
|
|
|
147
156
|
npx cc-safe-setup --diff <file> Compare your settings with another file
|
|
148
157
|
npx cc-safe-setup --lint Static analysis of hook configuration
|
|
149
158
|
npx cc-safe-setup --doctor Diagnose why hooks aren't working
|
|
159
|
+
npx cc-safe-setup --simulate "rm -rf /" See how hooks react to a command
|
|
160
|
+
npx cc-safe-setup --protect .env Block edits to a specific file/dir
|
|
161
|
+
npx cc-safe-setup --rules [file] Compile YAML rules into a single hook
|
|
150
162
|
npx cc-safe-setup --watch Live dashboard of blocked commands
|
|
151
163
|
npx cc-safe-setup --create "<desc>" Generate a custom hook from description
|
|
152
164
|
npx cc-safe-setup --test-hook <name> Test a specific hook with sample inputs
|
|
@@ -4260,6 +4272,581 @@ async function watch() {
|
|
|
4260
4272
|
}
|
|
4261
4273
|
}
|
|
4262
4274
|
|
|
4275
|
+
async function validateHooks() {
|
|
4276
|
+
const { spawnSync } = await import('child_process');
|
|
4277
|
+
const { readdirSync, renameSync } = await import('fs');
|
|
4278
|
+
|
|
4279
|
+
console.log();
|
|
4280
|
+
console.log(c.bold + ' cc-safe-setup --validate' + c.reset);
|
|
4281
|
+
console.log(c.dim + ' Checking all hooks for syntax errors and dangerous exit codes...' + c.reset);
|
|
4282
|
+
console.log();
|
|
4283
|
+
|
|
4284
|
+
if (!existsSync(HOOKS_DIR)) {
|
|
4285
|
+
console.log(c.yellow + ' No hooks directory found.' + c.reset);
|
|
4286
|
+
return;
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
const disabledDir = join(HOME, '.claude', 'hooks-disabled');
|
|
4290
|
+
const hooks = readdirSync(HOOKS_DIR).filter(f => f.endsWith('.sh'));
|
|
4291
|
+
let ok = 0, dangerous = 0;
|
|
4292
|
+
|
|
4293
|
+
for (const h of hooks) {
|
|
4294
|
+
const path = join(HOOKS_DIR, h);
|
|
4295
|
+
const name = h.replace('.sh', '');
|
|
4296
|
+
|
|
4297
|
+
// 1. Syntax check
|
|
4298
|
+
const syntax = spawnSync('bash', ['-n', path], { stdio: 'pipe', timeout: 5000 });
|
|
4299
|
+
if (syntax.status !== 0) {
|
|
4300
|
+
mkdirSync(disabledDir, { recursive: true });
|
|
4301
|
+
renameSync(path, join(disabledDir, h));
|
|
4302
|
+
console.log(c.red + ' ✗ ' + name + c.reset + ' — SYNTAX ERROR (exit ' + syntax.status + ')');
|
|
4303
|
+
console.log(c.dim + ' Moved to hooks-disabled/. A syntax error exit 2 blocks ALL tools.' + c.reset);
|
|
4304
|
+
dangerous++;
|
|
4305
|
+
continue;
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
// 2. Empty input test — exit 2 = would block all tools
|
|
4309
|
+
const test = spawnSync('bash', [path], {
|
|
4310
|
+
input: '{}', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
|
|
4311
|
+
});
|
|
4312
|
+
if (test.status === 2) {
|
|
4313
|
+
mkdirSync(disabledDir, { recursive: true });
|
|
4314
|
+
renameSync(path, join(disabledDir, h));
|
|
4315
|
+
console.log(c.red + ' ✗ ' + name + c.reset + ' — BLOCKS on empty input (exit 2)');
|
|
4316
|
+
console.log(c.dim + ' Moved to hooks-disabled/.' + c.reset);
|
|
4317
|
+
dangerous++;
|
|
4318
|
+
continue;
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
ok++;
|
|
4322
|
+
console.log(c.green + ' ✓ ' + c.reset + name);
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4325
|
+
console.log();
|
|
4326
|
+
if (dangerous > 0) {
|
|
4327
|
+
console.log(c.red + ' ' + dangerous + ' dangerous hook(s) disabled.' + c.reset);
|
|
4328
|
+
console.log(c.dim + ' Restart Claude Code to apply. Disabled hooks are in ~/.claude/hooks-disabled/' + c.reset);
|
|
4329
|
+
} else {
|
|
4330
|
+
console.log(c.green + ' All ' + ok + ' hooks passed validation.' + c.reset);
|
|
4331
|
+
}
|
|
4332
|
+
console.log();
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
async function safeMode(enable) {
|
|
4336
|
+
const { readdirSync, renameSync, rmdirSync } = await import('fs');
|
|
4337
|
+
const disabledDir = join(HOME, '.claude', 'hooks-disabled');
|
|
4338
|
+
const backupSettings = join(HOME, '.claude', 'settings-backup.json');
|
|
4339
|
+
|
|
4340
|
+
console.log();
|
|
4341
|
+
if (enable) {
|
|
4342
|
+
console.log(c.bold + ' cc-safe-setup --safe-mode' + c.reset);
|
|
4343
|
+
console.log(c.dim + ' Disabling all hooks...' + c.reset);
|
|
4344
|
+
|
|
4345
|
+
if (!existsSync(HOOKS_DIR)) {
|
|
4346
|
+
console.log(c.yellow + ' No hooks to disable.' + c.reset);
|
|
4347
|
+
return;
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4350
|
+
mkdirSync(disabledDir, { recursive: true });
|
|
4351
|
+
const hooks = readdirSync(HOOKS_DIR).filter(f => f.endsWith('.sh'));
|
|
4352
|
+
for (const h of hooks) {
|
|
4353
|
+
renameSync(join(HOOKS_DIR, h), join(disabledDir, h));
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
// Backup settings and clear hooks
|
|
4357
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
4358
|
+
const settings = readFileSync(SETTINGS_PATH, 'utf-8');
|
|
4359
|
+
writeFileSync(backupSettings, settings);
|
|
4360
|
+
const parsed = JSON.parse(settings);
|
|
4361
|
+
parsed.hooks = {};
|
|
4362
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(parsed, null, 2));
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
console.log(c.green + ' Safe mode ON.' + c.reset + ' ' + hooks.length + ' hooks disabled.');
|
|
4366
|
+
console.log(c.dim + ' Restart Claude Code. Run --safe-mode off to restore.' + c.reset);
|
|
4367
|
+
} else {
|
|
4368
|
+
console.log(c.bold + ' cc-safe-setup --safe-mode off' + c.reset);
|
|
4369
|
+
|
|
4370
|
+
if (existsSync(disabledDir)) {
|
|
4371
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
4372
|
+
const hooks = readdirSync(disabledDir).filter(f => f.endsWith('.sh'));
|
|
4373
|
+
for (const h of hooks) {
|
|
4374
|
+
renameSync(join(disabledDir, h), join(HOOKS_DIR, h));
|
|
4375
|
+
}
|
|
4376
|
+
try { rmdirSync(disabledDir); } catch {}
|
|
4377
|
+
console.log(c.green + ' Restored ' + hooks.length + ' hooks.' + c.reset);
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
if (existsSync(backupSettings)) {
|
|
4381
|
+
copyFileSync(backupSettings, SETTINGS_PATH);
|
|
4382
|
+
unlinkSync(backupSettings);
|
|
4383
|
+
console.log(c.green + ' Restored settings.json from backup.' + c.reset);
|
|
4384
|
+
}
|
|
4385
|
+
|
|
4386
|
+
console.log(c.dim + ' Restart Claude Code to activate hooks.' + c.reset);
|
|
4387
|
+
}
|
|
4388
|
+
console.log();
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
async function compileRules(rulesFile) {
|
|
4392
|
+
console.log();
|
|
4393
|
+
console.log(c.bold + ' cc-safe-setup --rules' + c.reset);
|
|
4394
|
+
|
|
4395
|
+
if (!existsSync(rulesFile)) {
|
|
4396
|
+
// Create example rules file
|
|
4397
|
+
const exampleDir = rulesFile.includes('/') ? rulesFile.substring(0, rulesFile.lastIndexOf('/')) : '.';
|
|
4398
|
+
mkdirSync(exampleDir, { recursive: true });
|
|
4399
|
+
writeFileSync(rulesFile, `# Claude Code Safety Rules
|
|
4400
|
+
# Run: npx cc-safe-setup --rules ${rulesFile}
|
|
4401
|
+
|
|
4402
|
+
# Block dangerous commands
|
|
4403
|
+
- block: "rm -rf on root or home"
|
|
4404
|
+
pattern: "rm\\\\s+-rf\\\\s+(\\\\/$|~)"
|
|
4405
|
+
|
|
4406
|
+
- block: "git push to main"
|
|
4407
|
+
pattern: "git\\\\s+push.*(main|master)"
|
|
4408
|
+
|
|
4409
|
+
- block: "git force push"
|
|
4410
|
+
pattern: "git\\\\s+push.*--force"
|
|
4411
|
+
|
|
4412
|
+
- block: "git add .env"
|
|
4413
|
+
pattern: "git\\\\s+add.*\\\\.env"
|
|
4414
|
+
|
|
4415
|
+
# Auto-approve safe commands
|
|
4416
|
+
- approve: "read-only commands"
|
|
4417
|
+
commands: [cat, head, tail, ls, grep, find, which, pwd, date]
|
|
4418
|
+
|
|
4419
|
+
- approve: "git read commands"
|
|
4420
|
+
pattern: "^\\\\s*git\\\\s+(status|log|diff|show|branch)"
|
|
4421
|
+
|
|
4422
|
+
- approve: "test runners"
|
|
4423
|
+
pattern: "^\\\\s*(npm\\\\s+test|pytest|go\\\\s+test|cargo\\\\s+test)"
|
|
4424
|
+
|
|
4425
|
+
# Protect files from edits
|
|
4426
|
+
- protect: ".env"
|
|
4427
|
+
- protect: "config/secrets.yaml"
|
|
4428
|
+
`);
|
|
4429
|
+
console.log(c.green + ' + ' + c.reset + 'Created ' + rulesFile + ' with example rules');
|
|
4430
|
+
console.log(c.dim + ' Edit the file, then run this command again to compile.' + c.reset);
|
|
4431
|
+
console.log();
|
|
4432
|
+
return;
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
// Parse YAML (simple parser — no dependency needed)
|
|
4436
|
+
const content = readFileSync(rulesFile, 'utf-8');
|
|
4437
|
+
const rules = [];
|
|
4438
|
+
let current = null;
|
|
4439
|
+
|
|
4440
|
+
for (const line of content.split('\n')) {
|
|
4441
|
+
const trimmed = line.trim();
|
|
4442
|
+
if (trimmed.startsWith('#') || !trimmed) continue;
|
|
4443
|
+
|
|
4444
|
+
const blockMatch = trimmed.match(/^-\s+block:\s+"(.+)"/);
|
|
4445
|
+
const approveMatch = trimmed.match(/^-\s+approve:\s+"(.+)"/);
|
|
4446
|
+
const protectMatch = trimmed.match(/^-\s+protect:\s+"(.+)"/);
|
|
4447
|
+
const patternMatch = trimmed.match(/^\s+pattern:\s+"(.+)"/);
|
|
4448
|
+
const commandsMatch = trimmed.match(/^\s+commands:\s+\[(.+)\]/);
|
|
4449
|
+
|
|
4450
|
+
if (blockMatch) { current = { type: 'block', name: blockMatch[1] }; rules.push(current); }
|
|
4451
|
+
else if (approveMatch) { current = { type: 'approve', name: approveMatch[1] }; rules.push(current); }
|
|
4452
|
+
else if (protectMatch) { rules.push({ type: 'protect', path: protectMatch[1] }); current = null; }
|
|
4453
|
+
else if (patternMatch && current) { current.pattern = patternMatch[1]; }
|
|
4454
|
+
else if (commandsMatch && current) { current.commands = commandsMatch[1].split(',').map(s => s.trim()); }
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
if (rules.length === 0) {
|
|
4458
|
+
console.log(c.yellow + ' No rules found in ' + rulesFile + c.reset);
|
|
4459
|
+
return;
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
console.log(c.dim + ' Compiling ' + rules.length + ' rules from ' + rulesFile + c.reset);
|
|
4463
|
+
|
|
4464
|
+
// Generate consolidated hook script
|
|
4465
|
+
let script = `#!/bin/bash
|
|
4466
|
+
# Auto-generated from: ${rulesFile}
|
|
4467
|
+
# Generated: $(date -Iseconds)
|
|
4468
|
+
# Rules: ${rules.length}
|
|
4469
|
+
# Regenerate: npx cc-safe-setup --rules ${rulesFile}
|
|
4470
|
+
|
|
4471
|
+
INPUT=$(cat)
|
|
4472
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
4473
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
4474
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
4475
|
+
|
|
4476
|
+
`;
|
|
4477
|
+
|
|
4478
|
+
// Block rules
|
|
4479
|
+
const blocks = rules.filter(r => r.type === 'block');
|
|
4480
|
+
if (blocks.length > 0) {
|
|
4481
|
+
script += '# === Block Rules ===\n';
|
|
4482
|
+
script += 'if [ "$TOOL" = "Bash" ] && [ -n "$COMMAND" ]; then\n';
|
|
4483
|
+
for (const rule of blocks) {
|
|
4484
|
+
if (rule.pattern) {
|
|
4485
|
+
script += ` if echo "$COMMAND" | grep -qE '${rule.pattern}'; then\n`;
|
|
4486
|
+
script += ` echo "BLOCKED: ${rule.name}" >&2\n`;
|
|
4487
|
+
script += ` exit 2\n`;
|
|
4488
|
+
script += ` fi\n`;
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
script += 'fi\n\n';
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
// Approve rules
|
|
4495
|
+
const approves = rules.filter(r => r.type === 'approve');
|
|
4496
|
+
if (approves.length > 0) {
|
|
4497
|
+
script += '# === Approve Rules ===\n';
|
|
4498
|
+
script += 'if [ "$TOOL" = "Bash" ] && [ -n "$COMMAND" ]; then\n';
|
|
4499
|
+
for (const rule of approves) {
|
|
4500
|
+
if (rule.commands) {
|
|
4501
|
+
const base = '$(echo "$COMMAND" | awk \'{ print $1 }\' | sed \'s|.*/||\')'
|
|
4502
|
+
script += ` BASE=${base}\n`;
|
|
4503
|
+
script += ` case "$BASE" in\n`;
|
|
4504
|
+
script += ` ${rule.commands.join('|')}) echo '{"decision":"approve","reason":"${rule.name}"}'; exit 0 ;;\n`;
|
|
4505
|
+
script += ` esac\n`;
|
|
4506
|
+
}
|
|
4507
|
+
if (rule.pattern) {
|
|
4508
|
+
script += ` if echo "$COMMAND" | grep -qE '${rule.pattern}'; then\n`;
|
|
4509
|
+
script += ` echo '{"decision":"approve","reason":"${rule.name}"}'\n`;
|
|
4510
|
+
script += ` exit 0\n`;
|
|
4511
|
+
script += ` fi\n`;
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
script += 'fi\n\n';
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
// Protect rules
|
|
4518
|
+
const protects = rules.filter(r => r.type === 'protect');
|
|
4519
|
+
if (protects.length > 0) {
|
|
4520
|
+
script += '# === Protect Rules ===\n';
|
|
4521
|
+
script += 'if [ "$TOOL" = "Edit" ] || [ "$TOOL" = "Write" ]; then\n';
|
|
4522
|
+
script += ' [ -z "$FILE" ] && exit 0\n';
|
|
4523
|
+
for (const rule of protects) {
|
|
4524
|
+
const path = rule.path;
|
|
4525
|
+
if (path.endsWith('/')) {
|
|
4526
|
+
script += ` [[ "$FILE" == *"${path}"* ]] && { jq -n '{"decision":"block","reason":"Protected: ${path}"}'; exit 0; }\n`;
|
|
4527
|
+
} else {
|
|
4528
|
+
script += ` [[ "$FILE" == *"${path}" ]] && { jq -n '{"decision":"block","reason":"Protected: ${path}"}'; exit 0; }\n`;
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
script += 'fi\n\n';
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4534
|
+
script += 'exit 0\n';
|
|
4535
|
+
|
|
4536
|
+
// Write hook
|
|
4537
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
4538
|
+
const hookPath = join(HOOKS_DIR, 'compiled-rules.sh');
|
|
4539
|
+
writeFileSync(hookPath, script);
|
|
4540
|
+
chmodSync(hookPath, 0o755);
|
|
4541
|
+
|
|
4542
|
+
// Register in settings.json
|
|
4543
|
+
let settings = {};
|
|
4544
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
4545
|
+
try { settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
|
|
4546
|
+
}
|
|
4547
|
+
if (!settings.hooks) settings.hooks = {};
|
|
4548
|
+
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
4549
|
+
|
|
4550
|
+
const hookCmd = `bash ${hookPath}`;
|
|
4551
|
+
|
|
4552
|
+
// Check all matchers for existing compiled-rules entry
|
|
4553
|
+
let found = false;
|
|
4554
|
+
for (const entry of settings.hooks.PreToolUse) {
|
|
4555
|
+
const idx = (entry.hooks || []).findIndex(h => h.command && h.command.includes('compiled-rules'));
|
|
4556
|
+
if (idx !== -1) {
|
|
4557
|
+
entry.hooks[idx] = { type: 'command', command: hookCmd };
|
|
4558
|
+
found = true;
|
|
4559
|
+
break;
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
if (!found) {
|
|
4564
|
+
// Add to catch-all matcher
|
|
4565
|
+
let allMatcher = settings.hooks.PreToolUse.find(e => e.matcher === '');
|
|
4566
|
+
if (!allMatcher) {
|
|
4567
|
+
allMatcher = { matcher: '', hooks: [] };
|
|
4568
|
+
settings.hooks.PreToolUse.push(allMatcher);
|
|
4569
|
+
}
|
|
4570
|
+
allMatcher.hooks.push({ type: 'command', command: hookCmd });
|
|
4571
|
+
}
|
|
4572
|
+
|
|
4573
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
4574
|
+
|
|
4575
|
+
// Summary
|
|
4576
|
+
console.log();
|
|
4577
|
+
for (const rule of rules) {
|
|
4578
|
+
if (rule.type === 'block') console.log(c.red + ' ✗ ' + c.reset + 'Block: ' + rule.name);
|
|
4579
|
+
else if (rule.type === 'approve') console.log(c.green + ' ✓ ' + c.reset + 'Approve: ' + rule.name);
|
|
4580
|
+
else if (rule.type === 'protect') console.log(c.blue + ' 🔒 ' + c.reset + 'Protect: ' + rule.path);
|
|
4581
|
+
}
|
|
4582
|
+
console.log();
|
|
4583
|
+
console.log(c.bold + ' ' + rules.length + ' rules compiled' + c.reset + ' → ' + hookPath);
|
|
4584
|
+
console.log(c.dim + ' Restart Claude Code to activate.' + c.reset);
|
|
4585
|
+
console.log(c.dim + ' Edit ' + rulesFile + ' and re-run to update.' + c.reset);
|
|
4586
|
+
console.log();
|
|
4587
|
+
}
|
|
4588
|
+
|
|
4589
|
+
async function protect(targetPath) {
|
|
4590
|
+
const { resolve, basename } = await import('path');
|
|
4591
|
+
|
|
4592
|
+
console.log();
|
|
4593
|
+
console.log(c.bold + ' cc-safe-setup --protect' + c.reset);
|
|
4594
|
+
|
|
4595
|
+
// Normalize path
|
|
4596
|
+
const normalized = targetPath.replace(/\\/g, '/');
|
|
4597
|
+
const safeName = basename(normalized).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
4598
|
+
const hookName = `protect-${safeName}.sh`;
|
|
4599
|
+
const hookPath = join(HOOKS_DIR, hookName);
|
|
4600
|
+
|
|
4601
|
+
// Generate hook script
|
|
4602
|
+
const isDir = normalized.endsWith('/');
|
|
4603
|
+
const pattern = isDir ? `*${normalized}*` : normalized;
|
|
4604
|
+
|
|
4605
|
+
const script = `#!/bin/bash
|
|
4606
|
+
# Auto-generated by: npx cc-safe-setup --protect ${targetPath}
|
|
4607
|
+
# Blocks Edit/Write to: ${normalized}
|
|
4608
|
+
INPUT=$(cat)
|
|
4609
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
4610
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
4611
|
+
|
|
4612
|
+
[[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]] && exit 0
|
|
4613
|
+
[ -z "$FILE" ] && exit 0
|
|
4614
|
+
|
|
4615
|
+
${isDir
|
|
4616
|
+
? `if [[ "$FILE" == *"${normalized}"* ]]; then`
|
|
4617
|
+
: `if [[ "$FILE" == *"${normalized}" ]] || [[ "$FILE" == *"/${normalized}" ]]; then`
|
|
4618
|
+
}
|
|
4619
|
+
jq -n '{"decision":"block","reason":"Protected path: ${normalized}"}'
|
|
4620
|
+
exit 0
|
|
4621
|
+
fi
|
|
4622
|
+
|
|
4623
|
+
exit 0
|
|
4624
|
+
`;
|
|
4625
|
+
|
|
4626
|
+
// Safety: write to /tmp first, validate, then copy to hooks/
|
|
4627
|
+
const tmpScript = '/tmp/cc-compiled-rules-' + Date.now() + '.sh';
|
|
4628
|
+
writeFileSync(tmpScript, script);
|
|
4629
|
+
chmodSync(tmpScript, 0o755);
|
|
4630
|
+
|
|
4631
|
+
// Validate 1: bash syntax check
|
|
4632
|
+
const { spawnSync: checkSpawn } = await import('child_process');
|
|
4633
|
+
const syntaxCheck = checkSpawn('bash', ['-n', tmpScript], { stdio: 'pipe' });
|
|
4634
|
+
if (syntaxCheck.status !== 0) {
|
|
4635
|
+
const err = (syntaxCheck.stderr || '').toString().trim();
|
|
4636
|
+
console.log(c.red + ' ERROR: Generated script has syntax errors:' + c.reset);
|
|
4637
|
+
console.log(c.dim + ' ' + err + c.reset);
|
|
4638
|
+
try { unlinkSync(tmpScript); } catch {}
|
|
4639
|
+
console.log(c.dim + ' Script NOT installed. Fix your rules and try again.' + c.reset);
|
|
4640
|
+
return;
|
|
4641
|
+
}
|
|
4642
|
+
|
|
4643
|
+
// Validate 2: empty input must NOT return exit 2 (which blocks all tools)
|
|
4644
|
+
const emptyTest = checkSpawn('bash', [tmpScript], {
|
|
4645
|
+
input: '{}', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
|
|
4646
|
+
});
|
|
4647
|
+
if (emptyTest.status === 2) {
|
|
4648
|
+
console.log(c.red + ' ERROR: Script returns exit 2 on empty input.' + c.reset);
|
|
4649
|
+
console.log(c.red + ' This would BLOCK ALL Claude Code tools.' + c.reset);
|
|
4650
|
+
try { unlinkSync(tmpScript); } catch {}
|
|
4651
|
+
return;
|
|
4652
|
+
}
|
|
4653
|
+
|
|
4654
|
+
// Validated — copy to hooks dir
|
|
4655
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
4656
|
+
const hookContent = readFileSync(tmpScript, 'utf-8');
|
|
4657
|
+
writeFileSync(hookPath, hookContent);
|
|
4658
|
+
chmodSync(hookPath, 0o755);
|
|
4659
|
+
try { unlinkSync(tmpScript); } catch {}
|
|
4660
|
+
console.log(c.green + ' + ' + c.reset + 'Created ' + hookName + ' (syntax verified)');
|
|
4661
|
+
|
|
4662
|
+
// Register in settings.json — use "Bash" matcher ONLY (never "")
|
|
4663
|
+
// "" matcher affects ALL tools and a broken hook would lock out the session
|
|
4664
|
+
let settings = {};
|
|
4665
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
4666
|
+
try { settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
|
|
4667
|
+
}
|
|
4668
|
+
if (!settings.hooks) settings.hooks = {};
|
|
4669
|
+
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
4670
|
+
|
|
4671
|
+
// Register under specific matchers based on rule types (NEVER use "" matcher)
|
|
4672
|
+
const hookCmd = `bash ${hookPath}`;
|
|
4673
|
+
const hasBlocks = rules.some(r => r.type === 'block');
|
|
4674
|
+
const hasApproves = rules.some(r => r.type === 'approve');
|
|
4675
|
+
const hasProtects = rules.some(r => r.type === 'protect');
|
|
4676
|
+
|
|
4677
|
+
// Block and approve rules need Bash matcher
|
|
4678
|
+
if (hasBlocks || hasApproves) {
|
|
4679
|
+
let bashMatcher = settings.hooks.PreToolUse.find(e => e.matcher === 'Bash');
|
|
4680
|
+
if (!bashMatcher) {
|
|
4681
|
+
bashMatcher = { matcher: 'Bash', hooks: [] };
|
|
4682
|
+
settings.hooks.PreToolUse.push(bashMatcher);
|
|
4683
|
+
}
|
|
4684
|
+
if (!bashMatcher.hooks.some(h => h.command === hookCmd)) {
|
|
4685
|
+
bashMatcher.hooks.push({ type: 'command', command: hookCmd });
|
|
4686
|
+
}
|
|
4687
|
+
}
|
|
4688
|
+
|
|
4689
|
+
// Protect rules need Edit|Write matcher
|
|
4690
|
+
if (hasProtects) {
|
|
4691
|
+
let editMatcher = settings.hooks.PreToolUse.find(e => e.matcher === 'Edit|Write');
|
|
4692
|
+
if (!editMatcher) {
|
|
4693
|
+
editMatcher = { matcher: 'Edit|Write', hooks: [] };
|
|
4694
|
+
settings.hooks.PreToolUse.push(editMatcher);
|
|
4695
|
+
}
|
|
4696
|
+
if (!editMatcher.hooks.some(h => h.command === hookCmd)) {
|
|
4697
|
+
editMatcher.hooks.push({ type: 'command', command: hookCmd });
|
|
4698
|
+
}
|
|
4699
|
+
}
|
|
4700
|
+
|
|
4701
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
4702
|
+
console.log(c.green + ' + ' + c.reset + 'Registered in settings.json');
|
|
4703
|
+
|
|
4704
|
+
console.log();
|
|
4705
|
+
console.log(c.bold + ' Protected: ' + c.reset + c.blue + normalized + c.reset);
|
|
4706
|
+
console.log(c.dim + ' Edit and Write to this path will be blocked.' + c.reset);
|
|
4707
|
+
console.log(c.dim + ' Restart Claude Code to activate.' + c.reset);
|
|
4708
|
+
console.log();
|
|
4709
|
+
|
|
4710
|
+
// Show how to unprotect
|
|
4711
|
+
console.log(c.dim + ' To unprotect: rm ' + hookPath + c.reset);
|
|
4712
|
+
console.log(c.dim + ' Then remove the entry from ~/.claude/settings.json' + c.reset);
|
|
4713
|
+
console.log();
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4716
|
+
async function simulate(command) {
|
|
4717
|
+
const { spawnSync } = await import('child_process');
|
|
4718
|
+
const { readdirSync } = await import('fs');
|
|
4719
|
+
|
|
4720
|
+
console.log();
|
|
4721
|
+
console.log(c.bold + ' cc-safe-setup --simulate' + c.reset);
|
|
4722
|
+
console.log(c.dim + ' Simulating how hooks would react to:' + c.reset);
|
|
4723
|
+
console.log(c.blue + ' $ ' + command + c.reset);
|
|
4724
|
+
console.log();
|
|
4725
|
+
|
|
4726
|
+
// Build fake PreToolUse JSON for Bash
|
|
4727
|
+
const fakeInput = JSON.stringify({
|
|
4728
|
+
tool_name: 'Bash',
|
|
4729
|
+
tool_input: { command }
|
|
4730
|
+
});
|
|
4731
|
+
|
|
4732
|
+
// Read settings to find registered hooks
|
|
4733
|
+
let settings = {};
|
|
4734
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
4735
|
+
try { settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4738
|
+
const hookEntries = settings.hooks?.PreToolUse || [];
|
|
4739
|
+
let tested = 0;
|
|
4740
|
+
let approved = 0;
|
|
4741
|
+
let blocked = 0;
|
|
4742
|
+
let passthrough = 0;
|
|
4743
|
+
|
|
4744
|
+
for (const entry of hookEntries) {
|
|
4745
|
+
const matcher = entry.matcher || '';
|
|
4746
|
+
// Check if matcher includes Bash (or matches all)
|
|
4747
|
+
if (matcher && !matcher.match(/Bash/i) && matcher !== '') continue;
|
|
4748
|
+
|
|
4749
|
+
for (const h of (entry.hooks || [])) {
|
|
4750
|
+
if (h.type !== 'command') continue;
|
|
4751
|
+
const cmd = h.command;
|
|
4752
|
+
|
|
4753
|
+
// Extract script name for display
|
|
4754
|
+
const parts = cmd.split(/\s+/);
|
|
4755
|
+
const scriptPath = parts[parts.length - 1];
|
|
4756
|
+
const scriptName = scriptPath.split('/').pop().replace('.sh', '');
|
|
4757
|
+
|
|
4758
|
+
// Resolve path
|
|
4759
|
+
const resolved = scriptPath.replace(/^~/, HOME);
|
|
4760
|
+
if (!existsSync(resolved)) {
|
|
4761
|
+
console.log(c.yellow + ' ? ' + c.reset + scriptName + c.dim + ' (script not found)' + c.reset);
|
|
4762
|
+
continue;
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4765
|
+
tested++;
|
|
4766
|
+
|
|
4767
|
+
// Run the hook with fake input
|
|
4768
|
+
const result = spawnSync('bash', [resolved], {
|
|
4769
|
+
input: fakeInput,
|
|
4770
|
+
timeout: 5000,
|
|
4771
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
4772
|
+
});
|
|
4773
|
+
|
|
4774
|
+
const stdout = (result.stdout || '').toString().trim();
|
|
4775
|
+
const stderr = (result.stderr || '').toString().trim();
|
|
4776
|
+
const exitCode = result.status;
|
|
4777
|
+
|
|
4778
|
+
if (exitCode === 2) {
|
|
4779
|
+
blocked++;
|
|
4780
|
+
console.log(c.red + ' ✗ BLOCK ' + c.reset + c.bold + scriptName + c.reset);
|
|
4781
|
+
if (stderr) console.log(c.dim + ' ' + stderr.split('\n')[0] + c.reset);
|
|
4782
|
+
} else if (stdout.includes('"approve"') || stdout.includes('"decision":"approve"')) {
|
|
4783
|
+
approved++;
|
|
4784
|
+
let reason = '';
|
|
4785
|
+
try { reason = JSON.parse(stdout).reason || JSON.parse(stdout).decision; } catch {}
|
|
4786
|
+
console.log(c.green + ' ✓ APPROVE ' + c.reset + scriptName + (reason ? c.dim + ' (' + reason + ')' + c.reset : ''));
|
|
4787
|
+
} else {
|
|
4788
|
+
passthrough++;
|
|
4789
|
+
console.log(c.dim + ' · pass ' + scriptName + c.reset);
|
|
4790
|
+
}
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
|
|
4794
|
+
// Also check installed hook scripts not in settings
|
|
4795
|
+
if (existsSync(HOOKS_DIR)) {
|
|
4796
|
+
const hookFiles = readdirSync(HOOKS_DIR).filter(f => f.endsWith('.sh'));
|
|
4797
|
+
const registeredScripts = new Set();
|
|
4798
|
+
for (const entry of hookEntries) {
|
|
4799
|
+
for (const h of (entry.hooks || [])) {
|
|
4800
|
+
if (h.command) registeredScripts.add(h.command.split('/').pop().replace('.sh', ''));
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
const unregistered = hookFiles.filter(f => !registeredScripts.has(f.replace('.sh', '')));
|
|
4805
|
+
if (unregistered.length > 0) {
|
|
4806
|
+
console.log();
|
|
4807
|
+
console.log(c.dim + ' Unregistered hooks (not in settings.json):' + c.reset);
|
|
4808
|
+
for (const f of unregistered.slice(0, 5)) {
|
|
4809
|
+
const resolved = join(HOOKS_DIR, f);
|
|
4810
|
+
const result = spawnSync('bash', [resolved], {
|
|
4811
|
+
input: fakeInput,
|
|
4812
|
+
timeout: 5000,
|
|
4813
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
4814
|
+
});
|
|
4815
|
+
const exitCode = result.status;
|
|
4816
|
+
const stdout = (result.stdout || '').toString().trim();
|
|
4817
|
+
const name = f.replace('.sh', '');
|
|
4818
|
+
|
|
4819
|
+
if (exitCode === 2) {
|
|
4820
|
+
console.log(c.red + ' ✗ BLOCK ' + c.reset + name + c.dim + ' (unregistered)' + c.reset);
|
|
4821
|
+
} else if (stdout.includes('"approve"')) {
|
|
4822
|
+
console.log(c.green + ' ✓ APPROVE ' + c.reset + name + c.dim + ' (unregistered)' + c.reset);
|
|
4823
|
+
} else {
|
|
4824
|
+
console.log(c.dim + ' · pass ' + name + ' (unregistered)' + c.reset);
|
|
4825
|
+
}
|
|
4826
|
+
}
|
|
4827
|
+
if (unregistered.length > 5) {
|
|
4828
|
+
console.log(c.dim + ' ... and ' + (unregistered.length - 5) + ' more' + c.reset);
|
|
4829
|
+
}
|
|
4830
|
+
}
|
|
4831
|
+
}
|
|
4832
|
+
|
|
4833
|
+
// Summary
|
|
4834
|
+
console.log();
|
|
4835
|
+
console.log(c.bold + ' Summary: ' + c.reset +
|
|
4836
|
+
(blocked > 0 ? c.red + blocked + ' blocked' + c.reset + ' · ' : '') +
|
|
4837
|
+
(approved > 0 ? c.green + approved + ' approved' + c.reset + ' · ' : '') +
|
|
4838
|
+
passthrough + ' passthrough');
|
|
4839
|
+
|
|
4840
|
+
if (blocked > 0) {
|
|
4841
|
+
console.log(c.red + ' → This command would be BLOCKED' + c.reset);
|
|
4842
|
+
} else if (approved > 0) {
|
|
4843
|
+
console.log(c.green + ' → This command would be auto-approved (no prompt)' + c.reset);
|
|
4844
|
+
} else {
|
|
4845
|
+
console.log(c.dim + ' → This command would trigger a permission prompt' + c.reset);
|
|
4846
|
+
}
|
|
4847
|
+
console.log();
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4263
4850
|
async function doctor() {
|
|
4264
4851
|
const { execSync, spawnSync } = await import('child_process');
|
|
4265
4852
|
const { statSync, readdirSync } = await import('fs');
|
|
@@ -4578,7 +5165,12 @@ async function main() {
|
|
|
4578
5165
|
if (LEARN) return learn();
|
|
4579
5166
|
if (SCAN) return scan();
|
|
4580
5167
|
if (FULL) return fullSetup();
|
|
5168
|
+
if (VALIDATE) return validateHooks();
|
|
5169
|
+
if (SAFE_MODE) return safeMode(!SAFE_MODE_OFF);
|
|
4581
5170
|
if (DOCTOR) return doctor();
|
|
5171
|
+
if (SIMULATE_CMD) return simulate(SIMULATE_CMD);
|
|
5172
|
+
if (PROTECT_PATH) return protect(PROTECT_PATH);
|
|
5173
|
+
if (RULES_FILE) return compileRules(RULES_FILE);
|
|
4582
5174
|
if (WATCH) return watch();
|
|
4583
5175
|
if (TEST_HOOK_IDX !== -1) return testHook(TEST_HOOK);
|
|
4584
5176
|
if (SAVE_PROFILE_IDX !== -1) return saveProfile(SAVE_PROFILE);
|
|
@@ -4707,7 +5299,7 @@ async function main() {
|
|
|
4707
5299
|
console.log(' ' + c.dim + 'Restart Claude Code to activate.' + c.reset);
|
|
4708
5300
|
console.log(' ' + c.dim + 'Verify:' + c.reset + ' ' + c.blue + 'npx cc-health-check' + c.reset);
|
|
4709
5301
|
console.log();
|
|
4710
|
-
console.log(' ' + c.dim + '
|
|
5302
|
+
console.log(' ' + c.dim + 'Need more? 16 hooks + templates for autonomous teams:' + c.reset);
|
|
4711
5303
|
console.log(' https://yurukusa.github.io/cc-ops-kit-landing/?utm_source=npm&utm_medium=cli&utm_campaign=safe-setup');
|
|
4712
5304
|
console.log();
|
|
4713
5305
|
}
|
package/package.json
CHANGED