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.
- package/examples/mcp-tool-guard.sh +26 -0
- package/examples/prompt-injection-guard.sh +22 -0
- package/index.mjs +384 -12
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
-
|
|
4656
|
+
const hookContent = readFileSync(tmpScript, 'utf-8');
|
|
4657
|
+
writeFileSync(hookPath, hookContent);
|
|
4309
4658
|
chmodSync(hookPath, 0o755);
|
|
4310
|
-
|
|
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
|
-
//
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
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
|
-
|
|
4328
|
-
if (
|
|
4329
|
-
editMatcher.hooks.
|
|
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