cc-safe-setup 28.4.0 → 28.4.2

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,13 +6,13 @@
6
6
 
7
7
  **One command to make Claude Code safe for autonomous operation.** [日本語](docs/README.ja.md)
8
8
 
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
-
11
9
  ```bash
12
10
  npx cc-safe-setup
13
11
  ```
14
12
 
15
- Installs 8 production-tested safety hooks in ~10 seconds. Zero dependencies. No manual configuration.
13
+ Installs 8 safety hooks in ~10 seconds. Blocks `rm -rf /`, prevents pushes to main, catches secret leaks, validates syntax after every edit. Zero dependencies.
14
+
15
+ [**Getting Started**](https://yurukusa.github.io/cc-safe-setup/getting-started.html) · [**All Tools**](https://yurukusa.github.io/cc-safe-setup/hub.html) · [**Recipes**](https://yurukusa.github.io/cc-safe-setup/recipes.html) · [Validate your settings.json](https://yurukusa.github.io/cc-safe-setup/validator.html)
16
16
 
17
17
  ```
18
18
  cc-safe-setup
@@ -0,0 +1,42 @@
1
+ INPUT=$(cat)
2
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
3
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
4
+ RESULT=$(echo "$INPUT" | jq -r '.tool_result // empty' 2>/dev/null)
5
+ [ -z "$COMMAND" ] && exit 0
6
+ if [ "${CC_REQUIRE_TESTS:-0}" = "1" ]; then
7
+ if echo "$COMMAND" | grep -qE '^\s*git\s+commit'; then
8
+ LAST_TEST=$(stat -c %Y coverage/.last-run.json 2>/dev/null || stat -c %Y test-results 2>/dev/null || echo 0)
9
+ NOW=$(date +%s)
10
+ if [ "$LAST_TEST" -eq 0 ] || [ $((NOW - LAST_TEST)) -gt 600 ]; then
11
+ echo "⚠ CLAUDE.md VIOLATION: Committed without running tests first" >&2
12
+ fi
13
+ fi
14
+ fi
15
+ if [ -n "${CC_ENFORCED_BRANCH}" ]; then
16
+ if echo "$COMMAND" | grep -qE "git\s+push.*${CC_ENFORCED_BRANCH}"; then
17
+ echo "⚠ CLAUDE.md VIOLATION: Pushed to protected branch '${CC_ENFORCED_BRANCH}'" >&2
18
+ fi
19
+ fi
20
+ if [ "${CC_NO_FORCE_PUSH:-1}" = "1" ]; then
21
+ if echo "$COMMAND" | grep -qE 'git\s+push.*--force'; then
22
+ echo "⚠ CLAUDE.md VIOLATION: Force push detected" >&2
23
+ fi
24
+ fi
25
+ MAX_FILES=${CC_MAX_FILES_PER_COMMIT:-20}
26
+ if echo "$COMMAND" | grep -qE '^\s*git\s+commit'; then
27
+ STAGED=$(git diff --cached --name-only 2>/dev/null | wc -l)
28
+ if [ "$STAGED" -gt "$MAX_FILES" ]; then
29
+ echo "⚠ CLAUDE.md VIOLATION: Committing $STAGED files (max: $MAX_FILES)" >&2
30
+ fi
31
+ fi
32
+ if echo "$COMMAND" | grep -qE '^\s*git\s+add'; then
33
+ STAGED_FILES=$(git diff --cached --name-only 2>/dev/null)
34
+ for f in $STAGED_FILES; do
35
+ [ -f "$f" ] || continue
36
+ if grep -qE 'console\.log\(|debugger;|print\(' "$f" 2>/dev/null; then
37
+ echo "⚠ CLAUDE.md REMINDER: Debug statements found in staged file: $f" >&2
38
+ break
39
+ fi
40
+ done
41
+ fi
42
+ 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,17 @@
1
+ COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
2
+ [ -z "$COMMAND" ] && exit 0
3
+ echo "$COMMAND" | grep -qE '^\s*git\s+commit' || exit 0
4
+ RECENT=0
5
+ TIMEOUT=${CC_TEST_TIMEOUT:-600}
6
+ NOW=$(date +%s)
7
+ for marker in coverage/.last-run.json test-results .nyc_output junit.xml; do
8
+ [ -e "$marker" ] || continue
9
+ MTIME=$(stat -c %Y "$marker" 2>/dev/null || echo 0)
10
+ [ $((NOW - MTIME)) -lt "$TIMEOUT" ] && RECENT=1 && break
11
+ done
12
+ if [ "$RECENT" -eq 0 ]; then
13
+ echo "BLOCKED: No recent test results (within $((TIMEOUT/60)) min)" >&2
14
+ echo "Run your test suite first, then commit." >&2
15
+ exit 2
16
+ fi
17
+ exit 0
@@ -0,0 +1,13 @@
1
+ COUNTER="${HOME}/.claude/session-tool-count"
2
+ COUNT=$(cat "$COUNTER" 2>/dev/null || echo 0)
3
+ COUNT=$((COUNT + 1))
4
+ echo "$COUNT" > "$COUNTER"
5
+ WARN1=${CC_USAGE_WARN1:-100}
6
+ WARN2=${CC_USAGE_WARN2:-200}
7
+ WARN3=${CC_USAGE_WARN3:-300}
8
+ case "$COUNT" in
9
+ "$WARN1") echo "NOTE: $COUNT tool calls this session" >&2 ;;
10
+ "$WARN2") echo "WARNING: $COUNT tool calls — consider wrapping up" >&2 ;;
11
+ "$WARN3") echo "ALERT: $COUNT tool calls — approaching typical session limit" >&2 ;;
12
+ esac
13
+ exit 0
package/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync } from 'fs';
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync, unlinkSync } from 'fs';
4
4
  import { join, dirname } from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { createInterface } from 'readline';
@@ -11,6 +11,9 @@ const HOME = homedir();
11
11
  const HOOKS_DIR = join(HOME, '.claude', 'hooks');
12
12
  const SETTINGS_PATH = join(HOME, '.claude', 'settings.json');
13
13
 
14
+ // Convert Windows backslash paths to bash-compatible forward slashes
15
+ const toBashPath = (p) => p.replace(/\\/g, '/');
16
+
14
17
  const c = {
15
18
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
16
19
  red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
@@ -114,10 +117,15 @@ const SUGGEST = process.argv.includes('--suggest');
114
117
  const INIT_PROJECT = process.argv.includes('--init-project');
115
118
  const SCORE_ONLY = process.argv.includes('--score');
116
119
  const CHANGELOG_CMD = process.argv.includes('--changelog');
120
+ const VALIDATE = process.argv.includes('--validate');
121
+ const SAFE_MODE = process.argv.includes('--safe-mode');
122
+ const SAFE_MODE_OFF = process.argv.includes('--safe-mode') && process.argv.includes('off');
117
123
  const SIMULATE_IDX = process.argv.findIndex(a => a === '--simulate');
118
124
  const SIMULATE_CMD = SIMULATE_IDX !== -1 ? process.argv.slice(SIMULATE_IDX + 1).join(' ') : null;
119
125
  const PROTECT_IDX = process.argv.findIndex(a => a === '--protect');
120
126
  const PROTECT_PATH = PROTECT_IDX !== -1 ? process.argv[PROTECT_IDX + 1] : null;
127
+ const RULES_IDX = process.argv.findIndex(a => a === '--rules');
128
+ const RULES_FILE = RULES_IDX !== -1 ? (process.argv[RULES_IDX + 1] || '.claude/rules.yaml') : null;
121
129
  const TEST_HOOK_IDX = process.argv.findIndex(a => a === '--test-hook');
122
130
  const TEST_HOOK = TEST_HOOK_IDX !== -1 ? process.argv[TEST_HOOK_IDX + 1] : null;
123
131
  const WHY_IDX = process.argv.findIndex(a => a === '--why');
@@ -127,51 +135,43 @@ if (HELP) {
127
135
  console.log(`
128
136
  cc-safe-setup — Make Claude Code safe for autonomous operation
129
137
 
130
- Usage:
131
- npx cc-safe-setup Install 8 safety hooks
132
- npx cc-safe-setup --status Check installed hooks
133
- npx cc-safe-setup --verify Test each hook with sample inputs
134
- npx cc-safe-setup --dry-run Preview without installing
135
- npx cc-safe-setup --uninstall Remove all installed hooks
136
- npx cc-safe-setup --examples List 30 example hooks (5 categories)
137
- npx cc-safe-setup --install-example <name> Install a specific example
138
- npx cc-safe-setup --full Complete setup: hooks + scan + audit + badge
139
- npx cc-safe-setup --audit Safety score (0-100) with fixes
140
- npx cc-safe-setup --audit --fix Auto-fix missing protections
141
- npx cc-safe-setup --audit --json Machine-readable output for CI/CD
142
- npx cc-safe-setup --scan Detect tech stack, recommend hooks
143
- npx cc-safe-setup --learn Learn from your block history
144
- npx cc-safe-setup --generate-ci Generate GitHub Actions workflow for safety checks
145
- npx cc-safe-setup --migrate Detect hooks from other projects, suggest replacements
146
- npx cc-safe-setup --compare <a> <b> Compare two hooks side-by-side
147
- npx cc-safe-setup --issues Show GitHub Issues each hook addresses
148
- npx cc-safe-setup --dashboard Real-time status dashboard
149
- npx cc-safe-setup --benchmark Measure hook execution time
150
- npx cc-safe-setup --share Generate shareable URL for your setup
151
- npx cc-safe-setup --diff <file> Compare your settings with another file
152
- npx cc-safe-setup --lint Static analysis of hook configuration
153
- npx cc-safe-setup --doctor Diagnose why hooks aren't working
154
- npx cc-safe-setup --simulate "rm -rf /" See how hooks react to a command
155
- npx cc-safe-setup --protect .env Block edits to a specific file/dir
156
- npx cc-safe-setup --watch Live dashboard of blocked commands
157
- npx cc-safe-setup --create "<desc>" Generate a custom hook from description
158
- npx cc-safe-setup --test-hook <name> Test a specific hook with sample inputs
159
- npx cc-safe-setup --save-profile <name> Save current hooks as a named profile
160
- npx cc-safe-setup --changelog Show what changed in recent versions
161
- npx cc-safe-setup --score Print safety score (0-100) and exit
162
- npx cc-safe-setup --init-project Complete project setup (CLAUDE.md + hooks + CI + .gitignore)
163
- npx cc-safe-setup --suggest Analyze project and predict risks → suggest hooks
164
- npx cc-safe-setup --why <hook> Why this hook exists (real incident + issue link)
165
- npx cc-safe-setup --replay Replay blocked commands timeline (demo/review)
166
- npx cc-safe-setup --guard "<rule>" Instantly enforce a rule (generate + install + activate)
167
- npx cc-safe-setup --diff-hooks <path> Compare hooks between two settings files
168
- npx cc-safe-setup --from-claudemd Convert CLAUDE.md rules into hooks
169
- npx cc-safe-setup --health Hook health dashboard (size, permissions, age)
170
- npx cc-safe-setup --migrate-from <tool> Migrate from safety-net/hooks-mastery/etc.
171
- npx cc-safe-setup --team Set up project-level hooks (commit to repo for team)
172
- npx cc-safe-setup --profile <level> Switch safety profile (strict/standard/minimal)
173
- npx cc-safe-setup --analyze Analyze what Claude did in your last session
174
- npx cc-safe-setup --shield Maximum safety in one command (fix + scan + install + CLAUDE.md)
138
+ Quick Start:
139
+ npx cc-safe-setup Install 8 safety hooks (30 sec)
140
+ npx cc-safe-setup --shield Maximum safety — one command
141
+ npx cc-safe-setup --doctor Diagnose hook problems
142
+
143
+ Protect:
144
+ --protect .env Block edits to a specific file
145
+ --guard "never delete prod" Enforce a rule from plain English
146
+ --simulate "rm -rf /" Preview how hooks react to a command
147
+ --validate Check all hooks for errors (auto-fix)
148
+ --safe-mode Emergency: disable all hooks
149
+
150
+ Install & Configure:
151
+ --install-example <name> Install from 300+ example hooks
152
+ --examples Browse examples by category
153
+ --from-claudemd Convert CLAUDE.md rules into hooks
154
+ --rules [file] Compile YAML rules into hooks
155
+ --team Set up project-level hooks for git
156
+ --profile <level> Switch profile (strict/standard/minimal)
157
+ --shield Full setup: hooks + scan + CLAUDE.md
158
+
159
+ Diagnose & Monitor:
160
+ --status / --verify Check installed hooks / test them
161
+ --doctor 13-point diagnostic
162
+ --audit [--fix] [--json] Safety score 0-100
163
+ --watch Live blocked command feed
164
+ --health Hook health dashboard
165
+ --suggest Predict risks from project analysis
166
+
167
+ Manage:
168
+ --dry-run / --uninstall Preview / remove hooks
169
+ --export / --import Share configuration
170
+ --diff <file> Compare settings
171
+ --lint Static analysis of config
172
+
173
+ More: --create, --why, --replay, --score, --changelog, --analyze,
174
+ --benchmark, --dashboard, --migrate-from, --init-project
175
175
  npx cc-safe-setup --quickfix Auto-detect and fix common Claude Code problems
176
176
  npx cc-safe-setup --stats Block statistics and patterns report
177
177
  npx cc-safe-setup --export Export hooks config for team sharing
@@ -305,6 +305,8 @@ function status() {
305
305
  console.log(' ' + c.dim + '+ ' + installedExamples.length + ' example hooks' + c.reset);
306
306
  }
307
307
  console.log();
308
+ console.log(c.dim + ' Tip: --validate to check health · --simulate "cmd" to test · --shield for max safety' + c.reset);
309
+ console.log();
308
310
 
309
311
  // Exit code for CI: 0 = all installed, 1 = missing hooks
310
312
  if (missing > 0) process.exit(1);
@@ -615,7 +617,7 @@ async function installExample(name) {
615
617
 
616
618
  const hookEntry = {
617
619
  matcher: matcher,
618
- hooks: [{ type: 'command', command: destPath }],
620
+ hooks: [{ type: 'command', command: toBashPath(destPath) }],
619
621
  };
620
622
 
621
623
  // Check if already installed
@@ -2214,7 +2216,7 @@ async function profile(level) {
2214
2216
 
2215
2217
  // Register all hooks in settings
2216
2218
  const hookFiles = prof.hooks.filter(h => existsSync(join(HOOKS_DIR, `${h}.sh`)));
2217
- const bashHooks = hookFiles.map(h => ({ type: 'command', command: `bash ${join(HOOKS_DIR, h + '.sh')}` }));
2219
+ const bashHooks = hookFiles.map(h => ({ type: 'command', command: `bash ${toBashPath(join(HOOKS_DIR, h + '.sh'))}` }));
2218
2220
 
2219
2221
  // Simplified: put all under PreToolUse Bash for now
2220
2222
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
@@ -3881,7 +3883,7 @@ exit 0`,
3881
3883
  if (!existing.some(cmd => cmd.includes(matched.name))) {
3882
3884
  settings.hooks[matched.trigger].push({
3883
3885
  matcher: matched.matcher,
3884
- hooks: [{ type: 'command', command: hookPath }],
3886
+ hooks: [{ type: 'command', command: toBashPath(hookPath) }],
3885
3887
  });
3886
3888
  writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
3887
3889
  console.log(c.green + ' ✓ Registered in settings.json' + c.reset);
@@ -4266,6 +4268,334 @@ async function watch() {
4266
4268
  }
4267
4269
  }
4268
4270
 
4271
+ async function validateHooks() {
4272
+ const { spawnSync } = await import('child_process');
4273
+ const { readdirSync, renameSync } = await import('fs');
4274
+
4275
+ console.log();
4276
+ console.log(c.bold + ' cc-safe-setup --validate' + c.reset);
4277
+ console.log(c.dim + ' Checking all hooks for syntax errors and dangerous exit codes...' + c.reset);
4278
+ console.log();
4279
+
4280
+ if (!existsSync(HOOKS_DIR)) {
4281
+ console.log(c.yellow + ' No hooks directory found.' + c.reset);
4282
+ return;
4283
+ }
4284
+
4285
+ const disabledDir = join(HOME, '.claude', 'hooks-disabled');
4286
+ const hooks = readdirSync(HOOKS_DIR).filter(f => f.endsWith('.sh'));
4287
+ let ok = 0, dangerous = 0;
4288
+
4289
+ for (const h of hooks) {
4290
+ const path = join(HOOKS_DIR, h);
4291
+ const name = h.replace('.sh', '');
4292
+
4293
+ // 1. Syntax check
4294
+ const syntax = spawnSync('bash', ['-n', path], { stdio: 'pipe', timeout: 5000 });
4295
+ if (syntax.status !== 0) {
4296
+ mkdirSync(disabledDir, { recursive: true });
4297
+ renameSync(path, join(disabledDir, h));
4298
+ console.log(c.red + ' ✗ ' + name + c.reset + ' — SYNTAX ERROR (exit ' + syntax.status + ')');
4299
+ console.log(c.dim + ' Moved to hooks-disabled/. A syntax error exit 2 blocks ALL tools.' + c.reset);
4300
+ dangerous++;
4301
+ continue;
4302
+ }
4303
+
4304
+ // 2. Empty input test — exit 2 = would block all tools
4305
+ const test = spawnSync('bash', [path], {
4306
+ input: '{}', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
4307
+ });
4308
+ if (test.status === 2) {
4309
+ mkdirSync(disabledDir, { recursive: true });
4310
+ renameSync(path, join(disabledDir, h));
4311
+ console.log(c.red + ' ✗ ' + name + c.reset + ' — BLOCKS on empty input (exit 2)');
4312
+ console.log(c.dim + ' Moved to hooks-disabled/.' + c.reset);
4313
+ dangerous++;
4314
+ continue;
4315
+ }
4316
+
4317
+ ok++;
4318
+ console.log(c.green + ' ✓ ' + c.reset + name);
4319
+ }
4320
+
4321
+ console.log();
4322
+ if (dangerous > 0) {
4323
+ console.log(c.red + ' ' + dangerous + ' dangerous hook(s) disabled.' + c.reset);
4324
+ console.log(c.dim + ' Restart Claude Code to apply. Disabled hooks are in ~/.claude/hooks-disabled/' + c.reset);
4325
+ } else {
4326
+ console.log(c.green + ' All ' + ok + ' hooks passed validation.' + c.reset);
4327
+ }
4328
+ console.log();
4329
+ }
4330
+
4331
+ async function safeMode(enable) {
4332
+ const { readdirSync, renameSync, rmdirSync } = await import('fs');
4333
+ const disabledDir = join(HOME, '.claude', 'hooks-disabled');
4334
+ const backupSettings = join(HOME, '.claude', 'settings-backup.json');
4335
+
4336
+ console.log();
4337
+ if (enable) {
4338
+ console.log(c.bold + ' cc-safe-setup --safe-mode' + c.reset);
4339
+ console.log(c.dim + ' Disabling all hooks...' + c.reset);
4340
+
4341
+ if (!existsSync(HOOKS_DIR)) {
4342
+ console.log(c.yellow + ' No hooks to disable.' + c.reset);
4343
+ return;
4344
+ }
4345
+
4346
+ mkdirSync(disabledDir, { recursive: true });
4347
+ const hooks = readdirSync(HOOKS_DIR).filter(f => f.endsWith('.sh'));
4348
+ for (const h of hooks) {
4349
+ renameSync(join(HOOKS_DIR, h), join(disabledDir, h));
4350
+ }
4351
+
4352
+ // Backup settings and clear hooks
4353
+ if (existsSync(SETTINGS_PATH)) {
4354
+ const settings = readFileSync(SETTINGS_PATH, 'utf-8');
4355
+ writeFileSync(backupSettings, settings);
4356
+ const parsed = JSON.parse(settings);
4357
+ parsed.hooks = {};
4358
+ writeFileSync(SETTINGS_PATH, JSON.stringify(parsed, null, 2));
4359
+ }
4360
+
4361
+ console.log(c.green + ' Safe mode ON.' + c.reset + ' ' + hooks.length + ' hooks disabled.');
4362
+ console.log(c.dim + ' Restart Claude Code. Run --safe-mode off to restore.' + c.reset);
4363
+ } else {
4364
+ console.log(c.bold + ' cc-safe-setup --safe-mode off' + c.reset);
4365
+
4366
+ if (existsSync(disabledDir)) {
4367
+ mkdirSync(HOOKS_DIR, { recursive: true });
4368
+ const hooks = readdirSync(disabledDir).filter(f => f.endsWith('.sh'));
4369
+ for (const h of hooks) {
4370
+ renameSync(join(disabledDir, h), join(HOOKS_DIR, h));
4371
+ }
4372
+ try { rmdirSync(disabledDir); } catch {}
4373
+ console.log(c.green + ' Restored ' + hooks.length + ' hooks.' + c.reset);
4374
+ }
4375
+
4376
+ if (existsSync(backupSettings)) {
4377
+ copyFileSync(backupSettings, SETTINGS_PATH);
4378
+ unlinkSync(backupSettings);
4379
+ console.log(c.green + ' Restored settings.json from backup.' + c.reset);
4380
+ }
4381
+
4382
+ console.log(c.dim + ' Restart Claude Code to activate hooks.' + c.reset);
4383
+ }
4384
+ console.log();
4385
+ }
4386
+
4387
+ async function compileRules(rulesFile) {
4388
+ console.log();
4389
+ console.log(c.bold + ' cc-safe-setup --rules' + c.reset);
4390
+
4391
+ if (!existsSync(rulesFile)) {
4392
+ // Create example rules file
4393
+ const exampleDir = rulesFile.includes('/') ? rulesFile.substring(0, rulesFile.lastIndexOf('/')) : '.';
4394
+ mkdirSync(exampleDir, { recursive: true });
4395
+ writeFileSync(rulesFile, `# Claude Code Safety Rules
4396
+ # Run: npx cc-safe-setup --rules ${rulesFile}
4397
+
4398
+ # Block dangerous commands
4399
+ - block: "rm -rf on root or home"
4400
+ pattern: "rm\\s+-rf\\s+(\\/$|~)"
4401
+
4402
+ - block: "git push to main"
4403
+ pattern: "git\\s+push.*(main|master)"
4404
+
4405
+ - block: "git force push"
4406
+ pattern: "git\\s+push.*--force"
4407
+
4408
+ - block: "git add .env"
4409
+ pattern: "git\\s+add.*\\.env"
4410
+
4411
+ # Auto-approve safe commands
4412
+ - approve: "read-only commands"
4413
+ commands: [cat, head, tail, ls, grep, find, which, pwd, date]
4414
+
4415
+ - approve: "git read commands"
4416
+ pattern: "^\\s*git\\s+(status|log|diff|show|branch)"
4417
+
4418
+ - approve: "test runners"
4419
+ pattern: "^\\s*(npm\\s+test|pytest|go\\s+test|cargo\\s+test)"
4420
+
4421
+ # Protect files from edits
4422
+ - protect: ".env"
4423
+ - protect: "config/secrets.yaml"
4424
+ `);
4425
+ console.log(c.green + ' + ' + c.reset + 'Created ' + rulesFile + ' with example rules');
4426
+ console.log(c.dim + ' Edit the file, then run this command again to compile.' + c.reset);
4427
+ console.log();
4428
+ return;
4429
+ }
4430
+
4431
+ // Parse YAML (simple parser — no dependency needed)
4432
+ const content = readFileSync(rulesFile, 'utf-8');
4433
+ const rules = [];
4434
+ let current = null;
4435
+
4436
+ for (const line of content.split('\n')) {
4437
+ const trimmed = line.trim();
4438
+ if (trimmed.startsWith('#') || !trimmed) continue;
4439
+
4440
+ const blockMatch = trimmed.match(/^-\s+block:\s+"(.+)"/);
4441
+ const approveMatch = trimmed.match(/^-\s+approve:\s+"(.+)"/);
4442
+ const protectMatch = trimmed.match(/^-\s+protect:\s+"(.+)"/);
4443
+ const patternMatch = trimmed.match(/^pattern:\s+"(.+)"/);
4444
+ const commandsMatch = trimmed.match(/^commands:\s+\[(.+)\]/);
4445
+
4446
+ if (blockMatch) { current = { type: 'block', name: blockMatch[1] }; rules.push(current); }
4447
+ else if (approveMatch) { current = { type: 'approve', name: approveMatch[1] }; rules.push(current); }
4448
+ else if (protectMatch) { rules.push({ type: 'protect', path: protectMatch[1] }); current = null; }
4449
+ else if (patternMatch && current) { current.pattern = patternMatch[1]; }
4450
+ else if (commandsMatch && current) { current.commands = commandsMatch[1].split(',').map(s => s.trim()); }
4451
+ }
4452
+
4453
+ if (rules.length === 0) {
4454
+ console.log(c.yellow + ' No rules found in ' + rulesFile + c.reset);
4455
+ return;
4456
+ }
4457
+
4458
+ console.log(c.dim + ' Compiling ' + rules.length + ' rules from ' + rulesFile + c.reset);
4459
+
4460
+ // Generate consolidated hook script
4461
+ let script = `#!/bin/bash
4462
+ # Auto-generated from: ${rulesFile}
4463
+ # Generated: $(date -Iseconds)
4464
+ # Rules: ${rules.length}
4465
+ # Regenerate: npx cc-safe-setup --rules ${rulesFile}
4466
+
4467
+ INPUT=$(cat)
4468
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
4469
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
4470
+ FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
4471
+
4472
+ `;
4473
+
4474
+ // Block rules
4475
+ const blocks = rules.filter(r => r.type === 'block');
4476
+ if (blocks.length > 0) {
4477
+ script += '# === Block Rules ===\n';
4478
+ script += 'if [ "$TOOL" = "Bash" ] && [ -n "$COMMAND" ]; then\n';
4479
+ for (const rule of blocks) {
4480
+ if (rule.pattern) {
4481
+ script += ` if echo "$COMMAND" | grep -qE '${rule.pattern}'; then\n`;
4482
+ script += ` echo "BLOCKED: ${rule.name}" >&2\n`;
4483
+ script += ` exit 2\n`;
4484
+ script += ` fi\n`;
4485
+ }
4486
+ }
4487
+ script += 'fi\n\n';
4488
+ }
4489
+
4490
+ // Approve rules
4491
+ const approves = rules.filter(r => r.type === 'approve');
4492
+ if (approves.length > 0) {
4493
+ script += '# === Approve Rules ===\n';
4494
+ script += 'if [ "$TOOL" = "Bash" ] && [ -n "$COMMAND" ]; then\n';
4495
+ for (const rule of approves) {
4496
+ if (rule.commands) {
4497
+ const base = '$(echo "$COMMAND" | awk \'{ print $1 }\' | sed \'s|.*/||\')'
4498
+ script += ` BASE=${base}\n`;
4499
+ script += ` case "$BASE" in\n`;
4500
+ script += ` ${rule.commands.join('|')}) echo '{"decision":"approve","reason":"${rule.name}"}'; exit 0 ;;\n`;
4501
+ script += ` esac\n`;
4502
+ }
4503
+ if (rule.pattern) {
4504
+ script += ` if echo "$COMMAND" | grep -qE '${rule.pattern}'; then\n`;
4505
+ script += ` echo '{"decision":"approve","reason":"${rule.name}"}'\n`;
4506
+ script += ` exit 0\n`;
4507
+ script += ` fi\n`;
4508
+ }
4509
+ }
4510
+ script += 'fi\n\n';
4511
+ }
4512
+
4513
+ // Protect rules
4514
+ const protects = rules.filter(r => r.type === 'protect');
4515
+ if (protects.length > 0) {
4516
+ script += '# === Protect Rules ===\n';
4517
+ script += 'if [ "$TOOL" = "Edit" ] || [ "$TOOL" = "Write" ]; then\n';
4518
+ script += ' [ -z "$FILE" ] && exit 0\n';
4519
+ for (const rule of protects) {
4520
+ const path = rule.path;
4521
+ if (path.endsWith('/')) {
4522
+ script += ` [[ "$FILE" == *"${path}"* ]] && { jq -n '{"decision":"block","reason":"Protected: ${path}"}'; exit 0; }\n`;
4523
+ } else {
4524
+ script += ` [[ "$FILE" == *"${path}" ]] && { jq -n '{"decision":"block","reason":"Protected: ${path}"}'; exit 0; }\n`;
4525
+ }
4526
+ }
4527
+ script += 'fi\n\n';
4528
+ }
4529
+
4530
+ script += 'exit 0\n';
4531
+
4532
+ // Write hook
4533
+ mkdirSync(HOOKS_DIR, { recursive: true });
4534
+ const hookPath = join(HOOKS_DIR, 'compiled-rules.sh');
4535
+ writeFileSync(hookPath, script);
4536
+ chmodSync(hookPath, 0o755);
4537
+
4538
+ // Syntax check — a broken hook returns exit 2 which blocks ALL tools
4539
+ const { execSync } = await import('child_process');
4540
+ try {
4541
+ execSync(`bash -n "${hookPath}"`, { stdio: 'pipe' });
4542
+ } catch (e) {
4543
+ const stderr = (e.stderr || '').toString().trim();
4544
+ unlinkSync(hookPath);
4545
+ console.log(c.red + ' ✗ Syntax error in generated hook — aborted' + c.reset);
4546
+ if (stderr) console.log(c.dim + ' ' + stderr + c.reset);
4547
+ console.log(c.dim + ' Check your rules file for unescaped special characters.' + c.reset);
4548
+ console.log();
4549
+ return;
4550
+ }
4551
+
4552
+ // Register in settings.json
4553
+ let settings = {};
4554
+ if (existsSync(SETTINGS_PATH)) {
4555
+ try { settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
4556
+ }
4557
+ if (!settings.hooks) settings.hooks = {};
4558
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
4559
+
4560
+ const hookCmd = `bash ${hookPath}`;
4561
+
4562
+ // Check all matchers for existing compiled-rules entry
4563
+ let found = false;
4564
+ for (const entry of settings.hooks.PreToolUse) {
4565
+ const idx = (entry.hooks || []).findIndex(h => h.command && h.command.includes('compiled-rules'));
4566
+ if (idx !== -1) {
4567
+ entry.hooks[idx] = { type: 'command', command: hookCmd };
4568
+ found = true;
4569
+ break;
4570
+ }
4571
+ }
4572
+
4573
+ if (!found) {
4574
+ // Add to catch-all matcher
4575
+ let allMatcher = settings.hooks.PreToolUse.find(e => e.matcher === '');
4576
+ if (!allMatcher) {
4577
+ allMatcher = { matcher: '', hooks: [] };
4578
+ settings.hooks.PreToolUse.push(allMatcher);
4579
+ }
4580
+ allMatcher.hooks.push({ type: 'command', command: hookCmd });
4581
+ }
4582
+
4583
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
4584
+
4585
+ // Summary
4586
+ console.log();
4587
+ for (const rule of rules) {
4588
+ if (rule.type === 'block') console.log(c.red + ' ✗ ' + c.reset + 'Block: ' + rule.name);
4589
+ else if (rule.type === 'approve') console.log(c.green + ' ✓ ' + c.reset + 'Approve: ' + rule.name);
4590
+ else if (rule.type === 'protect') console.log(c.blue + ' 🔒 ' + c.reset + 'Protect: ' + rule.path);
4591
+ }
4592
+ console.log();
4593
+ console.log(c.bold + ' ' + rules.length + ' rules compiled' + c.reset + ' → ' + hookPath);
4594
+ console.log(c.dim + ' Restart Claude Code to activate.' + c.reset);
4595
+ console.log(c.dim + ' Edit ' + rulesFile + ' and re-run to update.' + c.reset);
4596
+ console.log();
4597
+ }
4598
+
4269
4599
  async function protect(targetPath) {
4270
4600
  const { resolve, basename } = await import('path');
4271
4601
 
@@ -4303,13 +4633,44 @@ fi
4303
4633
  exit 0
4304
4634
  `;
4305
4635
 
4306
- // Write hook
4636
+ // Safety: write to /tmp first, validate, then copy to hooks/
4637
+ const tmpScript = '/tmp/cc-compiled-rules-' + Date.now() + '.sh';
4638
+ writeFileSync(tmpScript, script);
4639
+ chmodSync(tmpScript, 0o755);
4640
+
4641
+ // Validate 1: bash syntax check
4642
+ const { spawnSync: checkSpawn } = await import('child_process');
4643
+ const syntaxCheck = checkSpawn('bash', ['-n', tmpScript], { stdio: 'pipe' });
4644
+ if (syntaxCheck.status !== 0) {
4645
+ const err = (syntaxCheck.stderr || '').toString().trim();
4646
+ console.log(c.red + ' ERROR: Generated script has syntax errors:' + c.reset);
4647
+ console.log(c.dim + ' ' + err + c.reset);
4648
+ try { unlinkSync(tmpScript); } catch {}
4649
+ console.log(c.dim + ' Script NOT installed. Fix your rules and try again.' + c.reset);
4650
+ return;
4651
+ }
4652
+
4653
+ // Validate 2: empty input must NOT return exit 2 (which blocks all tools)
4654
+ const emptyTest = checkSpawn('bash', [tmpScript], {
4655
+ input: '{}', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
4656
+ });
4657
+ if (emptyTest.status === 2) {
4658
+ console.log(c.red + ' ERROR: Script returns exit 2 on empty input.' + c.reset);
4659
+ console.log(c.red + ' This would BLOCK ALL Claude Code tools.' + c.reset);
4660
+ try { unlinkSync(tmpScript); } catch {}
4661
+ return;
4662
+ }
4663
+
4664
+ // Validated — copy to hooks dir
4307
4665
  mkdirSync(HOOKS_DIR, { recursive: true });
4308
- writeFileSync(hookPath, script);
4666
+ const hookContent = readFileSync(tmpScript, 'utf-8');
4667
+ writeFileSync(hookPath, hookContent);
4309
4668
  chmodSync(hookPath, 0o755);
4310
- console.log(c.green + ' + ' + c.reset + 'Created ' + hookName);
4669
+ try { unlinkSync(tmpScript); } catch {}
4670
+ console.log(c.green + ' + ' + c.reset + 'Created ' + hookName + ' (syntax verified)');
4311
4671
 
4312
- // Register in settings.json
4672
+ // Register in settings.json — use "Bash" matcher ONLY (never "")
4673
+ // "" matcher affects ALL tools and a broken hook would lock out the session
4313
4674
  let settings = {};
4314
4675
  if (existsSync(SETTINGS_PATH)) {
4315
4676
  try { settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
@@ -4317,16 +4678,34 @@ exit 0
4317
4678
  if (!settings.hooks) settings.hooks = {};
4318
4679
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
4319
4680
 
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);
4681
+ // Register under specific matchers based on rule types (NEVER use "" matcher)
4682
+ const hookCmd = `bash ${hookPath}`;
4683
+ const hasBlocks = rules.some(r => r.type === 'block');
4684
+ const hasApproves = rules.some(r => r.type === 'approve');
4685
+ const hasProtects = rules.some(r => r.type === 'protect');
4686
+
4687
+ // Block and approve rules need Bash matcher
4688
+ if (hasBlocks || hasApproves) {
4689
+ let bashMatcher = settings.hooks.PreToolUse.find(e => e.matcher === 'Bash');
4690
+ if (!bashMatcher) {
4691
+ bashMatcher = { matcher: 'Bash', hooks: [] };
4692
+ settings.hooks.PreToolUse.push(bashMatcher);
4693
+ }
4694
+ if (!bashMatcher.hooks.some(h => h.command === hookCmd)) {
4695
+ bashMatcher.hooks.push({ type: 'command', command: hookCmd });
4696
+ }
4325
4697
  }
4326
4698
 
4327
- const hookCmd = `bash ${hookPath}`;
4328
- if (!editMatcher.hooks.some(h => h.command === hookCmd)) {
4329
- editMatcher.hooks.push({ type: 'command', command: hookCmd });
4699
+ // Protect rules need Edit|Write matcher
4700
+ if (hasProtects) {
4701
+ let editMatcher = settings.hooks.PreToolUse.find(e => e.matcher === 'Edit|Write');
4702
+ if (!editMatcher) {
4703
+ editMatcher = { matcher: 'Edit|Write', hooks: [] };
4704
+ settings.hooks.PreToolUse.push(editMatcher);
4705
+ }
4706
+ if (!editMatcher.hooks.some(h => h.command === hookCmd)) {
4707
+ editMatcher.hooks.push({ type: 'command', command: hookCmd });
4708
+ }
4330
4709
  }
4331
4710
 
4332
4711
  writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
@@ -4796,9 +5175,12 @@ async function main() {
4796
5175
  if (LEARN) return learn();
4797
5176
  if (SCAN) return scan();
4798
5177
  if (FULL) return fullSetup();
5178
+ if (VALIDATE) return validateHooks();
5179
+ if (SAFE_MODE) return safeMode(!SAFE_MODE_OFF);
4799
5180
  if (DOCTOR) return doctor();
4800
5181
  if (SIMULATE_CMD) return simulate(SIMULATE_CMD);
4801
5182
  if (PROTECT_PATH) return protect(PROTECT_PATH);
5183
+ if (RULES_FILE) return compileRules(RULES_FILE);
4802
5184
  if (WATCH) return watch();
4803
5185
  if (TEST_HOOK_IDX !== -1) return testHook(TEST_HOOK);
4804
5186
  if (SAVE_PROFILE_IDX !== -1) return saveProfile(SAVE_PROFILE);
@@ -4913,7 +5295,7 @@ async function main() {
4913
5295
  if (!exists) {
4914
5296
  settings.hooks[trigger].push({
4915
5297
  matcher: hook.matcher,
4916
- hooks: [{ type: 'command', command: hookPath }]
5298
+ hooks: [{ type: 'command', command: toBashPath(hookPath) }]
4917
5299
  });
4918
5300
  }
4919
5301
  }
@@ -4925,10 +5307,13 @@ async function main() {
4925
5307
  console.log();
4926
5308
  console.log(c.bold + ' Done.' + c.reset + ' ' + Object.keys(HOOKS).length + ' safety hooks installed.');
4927
5309
  console.log(' ' + c.dim + 'Restart Claude Code to activate.' + c.reset);
4928
- console.log(' ' + c.dim + 'Verify:' + c.reset + ' ' + c.blue + 'npx cc-health-check' + c.reset);
4929
5310
  console.log();
4930
- console.log(' ' + c.dim + 'Need more? 16 hooks + templates for autonomous teams:' + c.reset);
4931
- console.log(' https://yurukusa.github.io/cc-ops-kit-landing/?utm_source=npm&utm_medium=cli&utm_campaign=safe-setup');
5311
+ console.log(' ' + c.dim + 'Next steps:' + c.reset);
5312
+ console.log(' ' + c.blue + ' --doctor' + c.reset + ' Verify hooks work');
5313
+ console.log(' ' + c.blue + ' --simulate "cmd"' + c.reset + ' Test how hooks react');
5314
+ console.log(' ' + c.blue + ' --shield' + c.reset + ' Maximum safety (recommended)');
5315
+ console.log();
5316
+ console.log(' ' + c.dim + '22 web tools: https://yurukusa.github.io/cc-safe-setup/hub.html' + c.reset);
4932
5317
  console.log();
4933
5318
  }
4934
5319
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cc-safe-setup",
3
- "version": "28.4.0",
4
- "description": "One command to make Claude Code safe. 327 hooks (8 built-in + 319 examples). 45 CLI commands. 941 tests. 5 languages.",
3
+ "version": "28.4.2",
4
+ "description": "One command to make Claude Code safe. 336 hooks (8 built-in + 328 examples). 49 CLI commands. 978 tests. 5 languages.",
5
5
  "main": "index.mjs",
6
6
  "bin": {
7
7
  "cc-safe-setup": "index.mjs"