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 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 + 313 examples = **321 hooks**. 45 CLI commands. 941 tests. 5 languages. [**Hub**](https://yurukusa.github.io/cc-safe-setup/hub.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)
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 + 'Full kit (16 hooks + templates + tools):' + c.reset);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-safe-setup",
3
- "version": "28.3.5",
3
+ "version": "28.4.1",
4
4
  "description": "One command to make Claude Code safe. 327 hooks (8 built-in + 319 examples). 45 CLI commands. 941 tests. 5 languages.",
5
5
  "main": "index.mjs",
6
6
  "bin": {