cc-safe-setup 28.4.0 → 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.
@@ -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
package/index.mjs CHANGED
@@ -114,10 +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');
117
120
  const SIMULATE_IDX = process.argv.findIndex(a => a === '--simulate');
118
121
  const SIMULATE_CMD = SIMULATE_IDX !== -1 ? process.argv.slice(SIMULATE_IDX + 1).join(' ') : null;
119
122
  const PROTECT_IDX = process.argv.findIndex(a => a === '--protect');
120
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;
121
126
  const TEST_HOOK_IDX = process.argv.findIndex(a => a === '--test-hook');
122
127
  const TEST_HOOK = TEST_HOOK_IDX !== -1 ? process.argv[TEST_HOOK_IDX + 1] : null;
123
128
  const WHY_IDX = process.argv.findIndex(a => a === '--why');
@@ -153,6 +158,7 @@ if (HELP) {
153
158
  npx cc-safe-setup --doctor Diagnose why hooks aren't working
154
159
  npx cc-safe-setup --simulate "rm -rf /" See how hooks react to a command
155
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
156
162
  npx cc-safe-setup --watch Live dashboard of blocked commands
157
163
  npx cc-safe-setup --create "<desc>" Generate a custom hook from description
158
164
  npx cc-safe-setup --test-hook <name> Test a specific hook with sample inputs
@@ -4266,6 +4272,320 @@ async function watch() {
4266
4272
  }
4267
4273
  }
4268
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
+
4269
4589
  async function protect(targetPath) {
4270
4590
  const { resolve, basename } = await import('path');
4271
4591
 
@@ -4303,13 +4623,44 @@ fi
4303
4623
  exit 0
4304
4624
  `;
4305
4625
 
4306
- // Write hook
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
4307
4655
  mkdirSync(HOOKS_DIR, { recursive: true });
4308
- writeFileSync(hookPath, script);
4656
+ const hookContent = readFileSync(tmpScript, 'utf-8');
4657
+ writeFileSync(hookPath, hookContent);
4309
4658
  chmodSync(hookPath, 0o755);
4310
- console.log(c.green + ' + ' + c.reset + 'Created ' + hookName);
4659
+ try { unlinkSync(tmpScript); } catch {}
4660
+ console.log(c.green + ' + ' + c.reset + 'Created ' + hookName + ' (syntax verified)');
4311
4661
 
4312
- // Register in settings.json
4662
+ // Register in settings.json — use "Bash" matcher ONLY (never "")
4663
+ // "" matcher affects ALL tools and a broken hook would lock out the session
4313
4664
  let settings = {};
4314
4665
  if (existsSync(SETTINGS_PATH)) {
4315
4666
  try { settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
@@ -4317,16 +4668,34 @@ exit 0
4317
4668
  if (!settings.hooks) settings.hooks = {};
4318
4669
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
4319
4670
 
4320
- // Find or create Edit|Write matcher
4321
- let editMatcher = settings.hooks.PreToolUse.find(e => e.matcher === 'Edit|Write');
4322
- if (!editMatcher) {
4323
- editMatcher = { matcher: 'Edit|Write', hooks: [] };
4324
- settings.hooks.PreToolUse.push(editMatcher);
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
+ }
4325
4687
  }
4326
4688
 
4327
- const hookCmd = `bash ${hookPath}`;
4328
- if (!editMatcher.hooks.some(h => h.command === hookCmd)) {
4329
- editMatcher.hooks.push({ type: 'command', command: hookCmd });
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
+ }
4330
4699
  }
4331
4700
 
4332
4701
  writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
@@ -4796,9 +5165,12 @@ async function main() {
4796
5165
  if (LEARN) return learn();
4797
5166
  if (SCAN) return scan();
4798
5167
  if (FULL) return fullSetup();
5168
+ if (VALIDATE) return validateHooks();
5169
+ if (SAFE_MODE) return safeMode(!SAFE_MODE_OFF);
4799
5170
  if (DOCTOR) return doctor();
4800
5171
  if (SIMULATE_CMD) return simulate(SIMULATE_CMD);
4801
5172
  if (PROTECT_PATH) return protect(PROTECT_PATH);
5173
+ if (RULES_FILE) return compileRules(RULES_FILE);
4802
5174
  if (WATCH) return watch();
4803
5175
  if (TEST_HOOK_IDX !== -1) return testHook(TEST_HOOK);
4804
5176
  if (SAVE_PROFILE_IDX !== -1) return saveProfile(SAVE_PROFILE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-safe-setup",
3
- "version": "28.4.0",
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": {