diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cbe9d35..bd914aa 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -271,6 +271,19 @@ "hooks", "notification" ] + }, + { + "name": "guard-pack", + "source": "./plugins/guard-pack", + "description": "All six guard hooks in one Node process: config-guard, block-dangerous-commands, protect-secrets, protect-tests, git-safety, and case-insensitive-guard, first blocking verdict wins. One ~35 ms Node startup per tool call instead of six. Do not install alongside the individual guard plugins.", + "category": "security", + "tags": [ + "security", + "safety", + "guardrails", + "pretooluse", + "latency" + ] } ] } diff --git a/README.md b/README.md index 5cfc8e9..75bfc4c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # claude-code-hooks -πŸͺ Ready-to-use hooks for Claude Code, shipped as a 19-plugin installable marketplace: safety, automation, notifications, and more. +πŸͺ Ready-to-use hooks for Claude Code, shipped as a 20-plugin installable marketplace: safety, automation, notifications, and more. [![GitHub stars](https://img.shields.io/github/stars/karanb192/claude-code-hooks?style=social)](https://github.com/karanb192/claude-code-hooks) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![CI](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml/badge.svg)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml) -[![Tests](https://img.shields.io/badge/tests-1544%20passing-brightgreen)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml) +[![Tests](https://img.shields.io/badge/tests-1570%20passing-brightgreen)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml) **🌐 [Live site & catalog](https://karanb192.github.io/claude-code-hooks/)** @@ -22,7 +22,7 @@ -A growing collection of tested, documented hooks. Every one installs as a one-command Claude Code plugin: run `/plugin marketplace add karanb192/claude-code-hooks`, then `/plugin install @claude-code-hooks`; see [Install as a plugin](#-install-as-a-plugin) for the 19-plugin catalog. Prefer to own the file? Every plugin's script also works standalone: copy `plugins//.js` and wire it into `settings.json` yourself ([Quick Start](#-quick-start)). +A growing collection of tested, documented hooks. Every one installs as a one-command Claude Code plugin: run `/plugin marketplace add karanb192/claude-code-hooks`, then `/plugin install @claude-code-hooks`; see [Install as a plugin](#-install-as-a-plugin) for the 20-plugin catalog. Prefer to own the file? Every plugin's script also works standalone: copy `plugins//.js` and wire it into `settings.json` yourself ([Quick Start](#-quick-start)). --- @@ -161,6 +161,7 @@ This repo is a **Claude Code plugin marketplace**, so you can install a single h | [standup-autopilot](plugins/standup-autopilot) | Writes your daily standup from what your agents actually did across repos: captures tasks, tests, PRs, and blockers from session transcripts and re-injects yesterday's open blockers next session | `/standup-autopilot:standup` renders today's card on demand | | [dead-end-registry](plugins/dead-end-registry) | Remembers approaches you tried and reverted (reason + estimated token cost) and warns before you retry them: a prompt-submit card plus an ask-before-edit guard | `/dead-end-registry:dead-ends` renders the registry on demand | | [bounty-board](plugins/bounty-board) | Prices your repo's TODO/FIXME/HACK/skip debt as aging XP bounties, injects the top 3 as opportunistic side quests, and verifies + pays out bounties you genuinely clear | `/bounty-board:board` renders the board on demand | +| [guard-pack](plugins/guard-pack) | **All six guards below in one Node process**: one ~35 ms startup per tool call instead of six. First blocking verdict wins, in that guard's own format. Do not install alongside the individual guards. | `HOOK_SAFETY_LEVEL` applies to all six uniformly; `HOOK_ASK_*` and `CONFIG_GUARD_ALLOW` pass through | | [block-dangerous-commands](plugins/block-dangerous-commands) | Blocks dangerous shell commands (rm -rf ~, fork bombs, curl\|sh, force push to main) before they run | `HOOK_SAFETY_LEVEL` picks critical/high/strict (default high); `HOOK_ASK_*` prompts instead of denying | | [protect-secrets](plugins/protect-secrets) | Prevents reading, modifying, or exfiltrating sensitive files (.env, SSH keys, cloud creds, keystores) by denying or asking before the tool call runs | `HOOK_SAFETY_LEVEL` (critical/high/strict, default high), `HOOK_ASK_CRITICAL/HIGH/STRICT` ask mode; `/plugin install protect-secrets@claude-code-hooks` | | [git-safety](plugins/git-safety) | Branch-aware git guardrails + destructive gh CLI protection: blocks pushes to main/master, protected-branch deletion, direct changes on a protected branch, and gh pr merge/close, issue close, release/repo delete | `HOOK_SAFETY_LEVEL` = `critical`/`high`/`strict` (default `high`); `/plugin install git-safety@claude-code-hooks` | diff --git a/bench/README.md b/bench/README.md index f8ec68e..e6baa16 100644 --- a/bench/README.md +++ b/bench/README.md @@ -21,7 +21,7 @@ No dependencies. HOME is pointed at a throwaway temp dir during the run, so hook ## Scope -Covers the seven PreToolUse/PostToolUse hook plugins in `plugins/`: block-dangerous-commands, case-insensitive-guard, git-safety, protect-secrets, protect-tests, auto-stage, format-code. The format-code payload writes a small unformatted Python file, so `uv` and `ruff` must be on PATH (CI installs them); without them the run aborts instead of reporting a no-op. +Covers the ten PreToolUse/PostToolUse hook plugins in `plugins/`: block-dangerous-commands, case-insensitive-guard, git-safety, protect-secrets, protect-tests, config-guard, instructions-audit (its PreToolUse enforcement arm, measured with no lockdown flag set), guard-pack (all six guards in one process; compare its row against the sum of the six individual guard rows), auto-stage, format-code. The format-code payload writes a small unformatted Python file, so `uv` and `ruff` must be on PATH (CI installs them); without them the run aborts instead of reporting a no-op. Excluded: diff --git a/bench/RESULTS.md b/bench/RESULTS.md index 91b965b..df458d2 100644 --- a/bench/RESULTS.md +++ b/bench/RESULTS.md @@ -7,13 +7,16 @@ | Hook | Median (ms) | Min | Max | Mean | |------|------------:|----:|----:|-----:| -| block-dangerous-commands.js | 37.5 | 34.1 | 48.5 | 38.4 | -| case-insensitive-guard.js | 35.5 | 33.7 | 38.0 | 35.7 | -| git-safety.js | 34.7 | 33.3 | 36.3 | 34.6 | -| protect-secrets.js | 35.0 | 33.6 | 74.4 | 38.7 | -| protect-tests.js | 35.6 | 32.8 | 42.4 | 35.7 | -| auto-stage.js | 68.1 | 63.8 | 76.0 | 68.1 | -| format-code.js | 113.6 | 111.1 | 125.4 | 114.8 | +| block-dangerous-commands.js | 33.0 | 31.6 | 36.8 | 33.7 | +| case-insensitive-guard.js | 33.2 | 32.5 | 34.6 | 33.3 | +| git-safety.js | 33.6 | 31.6 | 35.6 | 33.5 | +| protect-secrets.js | 32.9 | 31.1 | 35.0 | 33.0 | +| protect-tests.js | 32.8 | 30.5 | 34.1 | 32.8 | +| config-guard.js | 33.1 | 31.7 | 36.5 | 33.4 | +| instructions-audit.js | 34.1 | 31.9 | 41.3 | 35.0 | +| guard-pack.js | 38.0 | 35.7 | 69.1 | 39.9 | +| auto-stage.js | 61.8 | 59.5 | 69.8 | 62.8 | +| format-code.js | 112.5 | 108.5 | 116.5 | 112.7 | Single-machine spot measurement. Numbers are dominated by Node interpreter startup, not hook logic; treat them as an upper bound on per-call overhead, diff --git a/bench/run.mjs b/bench/run.mjs index 87d34e4..dd9f5ee 100644 --- a/bench/run.mjs +++ b/bench/run.mjs @@ -61,6 +61,24 @@ const HOOKS = [ payload: { ...base, hook_event_name: 'PreToolUse', tool_name: 'Write', tool_input: { file_path: path.join(repoDir, 'src', 'app.js'), content: 'module.exports = () => 42;\n' } }, }, + { + script: 'plugins/config-guard/config-guard.js', + payload: { ...base, hook_event_name: 'PreToolUse', tool_name: 'Bash', + tool_input: { command: 'git status && ls -la src/' } }, + }, + { + // The PreToolUse enforcement arm: with no lockdown flag for the session, + // this is its cost on every tool call for anyone who installs it. + script: 'plugins/instructions-audit/instructions-audit.js', + payload: { ...base, hook_event_name: 'PreToolUse', tool_name: 'Bash', + tool_input: { command: 'git status && ls -la src/' } }, + }, + { + // All six guards in one process; compare against the sum of the six rows above. + script: 'plugins/guard-pack/guard-pack.js', + payload: { ...base, hook_event_name: 'PreToolUse', tool_name: 'Bash', + tool_input: { command: 'git status && ls -la src/' } }, + }, { script: 'plugins/auto-stage/auto-stage.js', payload: { ...base, hook_event_name: 'PostToolUse', tool_name: 'Write', diff --git a/plugins/guard-pack/.claude-plugin/plugin.json b/plugins/guard-pack/.claude-plugin/plugin.json new file mode 100644 index 0000000..64968ec --- /dev/null +++ b/plugins/guard-pack/.claude-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "guard-pack", + "description": "All six guard hooks in one Node process: config-guard, block-dangerous-commands, protect-secrets, protect-tests, git-safety, and case-insensitive-guard, evaluated in that order with the first blocking verdict winning in that guard's own output format. Installing the guards individually costs six Node startups per matching tool call (about 35 ms each); the pack pays one. Env vars pass straight through (HOOK_SAFETY_LEVEL for all guards uniformly, HOOK_ASK_*, CONFIG_GUARD_ALLOW). Do not install alongside the individual guard plugins: duplicated guards run twice.", + "version": "1.0.0", + "author": { + "name": "Karan Bansal", + "url": "https://github.com/karanb192" + }, + "homepage": "https://github.com/karanb192/claude-code-hooks", + "license": "MIT", + "keywords": [ + "claude-code", + "hooks", + "security", + "safety", + "guardrails", + "pretooluse", + "latency" + ], + "hooks": "./hooks/hooks.json" +} diff --git a/plugins/guard-pack/README.md b/plugins/guard-pack/README.md new file mode 100644 index 0000000..be9ca2d --- /dev/null +++ b/plugins/guard-pack/README.md @@ -0,0 +1,30 @@ +# guard-pack + +> All six guard hooks in one Node process. Installing them individually costs six Node startups per matching tool call; the pack pays one. Measured ([bench/RESULTS.md](../../bench/RESULTS.md)): 38 ms median for the pack vs 199 ms for the six guards separately on the same machine. + +One PreToolUse registration on `Bash|Read|Edit|MultiEdit|Write` evaluates, in order: [config-guard](../config-guard), [block-dangerous-commands](../block-dangerous-commands), [protect-secrets](../protect-secrets), [protect-tests](../protect-tests), [git-safety](../git-safety), [case-insensitive-guard](../case-insensitive-guard). Cheap string checks run first; the guards that touch the filesystem or spawn git run last. The first blocking verdict wins and is emitted in that guard's own format (same emoji, same `[id]`, same reason text) with a `(via guard-pack)` suffix. A guard that throws is logged and skipped, so one broken guard can never switch off the other five. + +The guard scripts in `lib/` are byte-identical copies of the individual plugin scripts, pinned by a test, so the pack can never drift from the standalone guards. + +## Install + +``` +/plugin marketplace add karanb192/claude-code-hooks +/plugin install guard-pack@claude-code-hooks +``` + +Restart Claude Code, done. + +**Do not install the pack alongside the individual guard plugins** (or a manual `settings.json` registration of any of the six): every duplicated guard runs twice on each matching tool call, with double latency and double denials. Pick one or the other. + +## Configuration + +The pack adds no configuration of its own; the guards' env vars pass straight through because the modules read them directly: + +- `HOOK_SAFETY_LEVEL` = `critical` | `high` | `strict`: applies to every guard in the pack uniformly (each falls back to its own default on an invalid value). Want different levels per guard? Install the individual guard plugins instead. +- `HOOK_ASK_CRITICAL` / `HOOK_ASK_HIGH` / `HOOK_ASK_STRICT` = `true`: ask instead of deny, for the guards that support ask mode (config-guard, block-dangerous-commands, protect-secrets, case-insensitive-guard). protect-tests and git-safety always deny. +- `CONFIG_GUARD_ALLOW` = `true`: skips config-guard for an intentional, human-approved config edit. The other five guards still run. + +## What gets recorded + +One line per verdict in `~/.claude/hooks-logs/` (guard name, rule id, decision, tool). The pack makes no network calls; everything stays on your machine. diff --git a/plugins/guard-pack/guard-pack.js b/plugins/guard-pack/guard-pack.js new file mode 100644 index 0000000..7e512fe --- /dev/null +++ b/plugins/guard-pack/guard-pack.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +/** + * Guard Pack - PreToolUse Hook for Bash|Read|Edit|MultiEdit|Write + * All six guard hooks in ONE Node process. Installing the guards + * individually costs six Node startups per matching tool call (about 35 ms + * each, see bench/RESULTS.md); this pack pays one. + * + * Evaluation order (cheap string checks first, filesystem and subprocess + * work last): config-guard, block-dangerous-commands, protect-secrets, + * protect-tests, git-safety, case-insensitive-guard. The first blocking + * verdict wins and is emitted in that guard's own output format, suffixed + * "(via guard-pack)". A guard that throws is logged and skipped so one + * broken guard can never switch off the other five (fail-open per guard, + * same convention as the standalone hooks). + * + * The guard scripts in lib/ are byte-identical copies of the individual + * plugin scripts; a repo test pins them, so they cannot drift. All the + * guards' env vars pass straight through, since the modules read them + * directly: HOOK_SAFETY_LEVEL (applies to every guard in the pack + * uniformly), HOOK_ASK_CRITICAL / HOOK_ASK_HIGH / HOOK_ASK_STRICT, and + * CONFIG_GUARD_ALLOW. Want different safety levels per guard? Install the + * individual guard plugins instead of the pack. + * + * Do NOT install this pack alongside the individual guard plugins (or a + * manual registration of any of the six): every duplicated guard runs + * twice on each matching tool call. Logs to: ~/.claude/hooks-logs/ + * + * Setup (plugin, recommended): + * /plugin marketplace add karanb192/claude-code-hooks + * /plugin install guard-pack@claude-code-hooks + * The plugin's hooks/hooks.json registers this script automatically. + */ + +const fs = require('fs'); +const path = require('path'); + +const LIB = path.join(__dirname, 'lib'); +const LOG_DIR = path.join(process.env.HOME || '/tmp', '.claude', 'hooks-logs'); + +const envBool = (key) => process.env[key] === 'true'; + +// Two emoji vocabularies exist across the guards; kept per guard so the +// pack's output is character-identical to the standalone hook's. +const STD_EMOJIS = { critical: '🚨', high: 'β›”', strict: '⚠️' }; +const LOCK_EMOJIS = { critical: 'πŸ”’', high: 'πŸ›‘οΈ', strict: '⚠️' }; + +// Each entry mirrors its guard's main(): same tool filter, same escape +// hatches, same reason template. run() returns null (pass) or +// { id, level, ask, reason } with reason lacking only the emoji prefix. +const GUARDS = [ + { + name: 'config-guard', + emojis: LOCK_EMOJIS, + skip: () => envBool('CONFIG_GUARD_ALLOW'), + run(mod, tool, input) { + const r = mod.checkTool(tool, input || {}); + if (!r.blocked) return null; + return { + id: r.id, level: r.level, ask: mod.ASK[r.level] === true, + reason: `[${r.id}] ${r.reason}. Guardrail config is protected; if this change is intentional and human-approved, set CONFIG_GUARD_ALLOW=true for this call or edit the file yourself.`, + }; + }, + }, + { + name: 'block-dangerous-commands', + emojis: STD_EMOJIS, + run(mod, tool, input) { + if (tool !== 'Bash') return null; + const r = mod.checkCommand(input?.command || ''); + if (!r.blocked) return null; + const p = r.pattern; + return { id: p.id, level: p.level, ask: mod.ASK[p.level] === true, reason: `[${p.id}] ${p.reason}` }; + }, + }, + { + name: 'protect-secrets', + emojis: STD_EMOJIS, + run(mod, tool, input) { + if (!['Read', 'Edit', 'Write', 'Bash'].includes(tool)) return null; + const r = mod.check(tool, input); + if (!r.blocked) return null; + const p = r.pattern; + const action = { Read: 'read', Edit: 'modify', Write: 'write to', Bash: 'execute' }[tool]; + return { id: p.id, level: p.level, ask: mod.ASK[p.level] === true, reason: `[${p.id}] Cannot ${action}: ${p.reason}` }; + }, + }, + { + name: 'protect-tests', + emojis: STD_EMOJIS, + run(mod, tool, input) { + const r = mod.checkTool(tool, input || {}); + if (!r.blocked) return null; + return { + id: r.id, level: r.level, ask: false, + reason: `[${r.id}] ${r.reason}. Fix the code, don't disable the test: or run this manually if the removal is intentional.`, + }; + }, + }, + { + name: 'git-safety', + emojis: STD_EMOJIS, + run(mod, tool, input) { + if (tool !== 'Bash') return null; + const r = mod.checkCommand(input?.command || ''); + if (!r.blocked) return null; + return { id: r.pattern.id, level: r.pattern.level, ask: false, reason: `[${r.pattern.id}] ${r.reason}` }; + }, + }, + { + name: 'case-insensitive-guard', + emojis: STD_EMOJIS, + run(mod, tool, input, cwd) { + if (tool !== 'Bash' || !input?.command) return null; + const r = mod.checkCommand(input.command, cwd); + if (!r.blocked) return null; + const hit = r.hit; + return { + id: 'case-collision', level: hit.level, ask: mod.ASK[hit.level] === true, + reason: `[case-collision] ${hit.cmd} targets '${hit.typed}' but this case-insensitive directory contains '${hit.actual}': same path on disk, so the differently-cased entry would be hit`, + }; + }, + }, +]; + +function log(data) { + try { + if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + const file = path.join(LOG_DIR, `${new Date().toISOString().slice(0, 10)}.jsonl`); + fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), hook: 'guard-pack', ...data }) + '\n'); + } catch {} +} + +// Evaluate all guards for one event; returns null or the winning verdict +// with its guard attached. Exported for tests. +function evaluate(toolName, toolInput, cwd) { + for (const g of GUARDS) { + try { + if (g.skip && g.skip()) continue; + const mod = require(path.join(LIB, `${g.name}.js`)); + const verdict = g.run(mod, toolName, toolInput, cwd); + if (verdict) return { guard: g.name, emojis: g.emojis, ...verdict }; + } catch (e) { + log({ level: 'ERROR', guard: g.name, error: e.message }); + } + } + return null; +} + +async function main() { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + + try { + const data = JSON.parse(input); + const { tool_name, tool_input, session_id, cwd, permission_mode } = data; + if (!['Bash', 'Read', 'Edit', 'MultiEdit', 'Write'].includes(tool_name)) return console.log('{}'); + + const v = evaluate(tool_name, tool_input, cwd); + if (!v) return console.log('{}'); + + const decision = v.ask ? 'ask' : 'deny'; + log({ level: v.ask ? 'ASK' : 'BLOCKED', guard: v.guard, id: v.id, priority: v.level, decision, tool: tool_name, session_id, cwd, permission_mode }); + return console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: decision, + permissionDecisionReason: `${v.emojis[v.level]} ${v.reason} (via guard-pack)`, + }, + })); + } catch (e) { + log({ level: 'ERROR', error: e.message }); + console.log('{}'); + } +} + +if (require.main === module) { + main(); +} else { + module.exports = { GUARDS, evaluate }; +} diff --git a/plugins/guard-pack/hooks/hooks.json b/plugins/guard-pack/hooks/hooks.json new file mode 100644 index 0000000..08d09ba --- /dev/null +++ b/plugins/guard-pack/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Read|Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/guard-pack.js\"" + } + ] + } + ] + } +} diff --git a/plugins/guard-pack/lib/block-dangerous-commands.js b/plugins/guard-pack/lib/block-dangerous-commands.js new file mode 100755 index 0000000..1b61465 --- /dev/null +++ b/plugins/guard-pack/lib/block-dangerous-commands.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * Block Dangerous Commands - PreToolUse Hook for Bash + * Blocks dangerous patterns before execution. Logs to: ~/.claude/hooks-logs/ + * + * SAFETY_LEVEL: 'critical' | 'high' | 'strict' (default: 'high') + * critical - Only catastrophic: rm -rf ~, dd to disk, fork bombs + * high - + risky: force push main, secrets exposure, git reset --hard + * strict - + cautionary: any force push, sudo rm, docker prune + * Override via HOOK_SAFETY_LEVEL in the hook command; invalid values fall + * back to the default (don't edit this file: plugin updates overwrite it). + * + * Ask mode (opt-in, per level): set HOOK_ASK_CRITICAL / HOOK_ASK_HIGH / + * HOOK_ASK_STRICT to the literal string "true" in the hook command to have + * that level prompt the user ("ask") instead of blocking outright ("deny"). + * e.g. "command": "HOOK_ASK_STRICT=true node /path/to/block-dangerous-commands.js" + * + * Install as a plugin (recommended): + * /plugin install block-dangerous-commands@claude-code-hooks + * This auto-wires the PreToolUse hook on Bash; configure via the env vars above. + * + * Or wire it up the classic way (copy the script, then in .claude/settings.json): + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "Bash", + * "hooks": [{ "type": "command", "command": "node /path/to/block-dangerous-commands.js" }] + * }] + * } + * } + */ + +const fs = require('fs'); +const path = require('path'); + +// Safety level, env-overridable via HOOK_SAFETY_LEVEL. Invalid or unset values +// fall back to the default. +const DEFAULT_SAFETY_LEVEL = 'high'; +const SAFETY_LEVEL = ['critical', 'high', 'strict'].includes(process.env.HOOK_SAFETY_LEVEL) + ? process.env.HOOK_SAFETY_LEVEL + : DEFAULT_SAFETY_LEVEL; + +// Ask mode per level: if true, prompts the user instead of blocking outright. +// When ask=true, the hook returns decision "ask" so Claude Code shows the +// reason and lets the user decide. When ask=false (default), the hook denies. +// Env overrides: HOOK_ASK_CRITICAL, HOOK_ASK_HIGH, HOOK_ASK_STRICT +const envBool = (key, fallback) => key in process.env ? process.env[key] === 'true' : fallback; +const ASK = { + critical: envBool('HOOK_ASK_CRITICAL', false), + high: envBool('HOOK_ASK_HIGH', false), + strict: envBool('HOOK_ASK_STRICT', false), +}; + +const PATTERNS = [ + // CRITICAL - Catastrophic, unrecoverable + { level: 'critical', id: 'rm-home', regex: /\brm\s+(-.+\s+)*["']?~\/?["']?(\s|$|[;&|])/, reason: 'rm targeting home directory' }, + { level: 'critical', id: 'rm-home-var', regex: /\brm\s+(-.+\s+)*["']?\$HOME["']?(\s|$|[;&|])/, reason: 'rm targeting $HOME' }, + { level: 'critical', id: 'rm-home-trailing', regex: /\brm\s+.+\s+["']?(~\/?|\$HOME)["']?(\s*$|[;&|])/, reason: 'rm with trailing ~/ or $HOME' }, + { level: 'critical', id: 'rm-root', regex: /\brm\s+(-.+\s+)*\/(\*|\s|$|[;&|])/, reason: 'rm targeting root filesystem' }, + { level: 'critical', id: 'rm-system', regex: /\brm\s+(-.+\s+)*\/(etc|usr|var|bin|sbin|lib|boot|dev|proc|sys)(\/|\s|$)/, reason: 'rm targeting system directory' }, + { level: 'critical', id: 'rm-cwd', regex: /\brm\s+(-.+\s+)*(\.\/?|\*|\.\/\*)(\s|$|[;&|])/, reason: 'rm deleting current directory contents' }, + { level: 'critical', id: 'dd-disk', regex: /\bdd\b.+of=\/dev\/(sd[a-z]|nvme|hd[a-z]|vd[a-z]|xvd[a-z])/, reason: 'dd writing to disk device' }, + { level: 'critical', id: 'mkfs', regex: /\bmkfs(\.\w+)?\s+\/dev\/(sd[a-z]|nvme|hd[a-z]|vd[a-z])/, reason: 'mkfs formatting disk' }, + { level: 'critical', id: 'fork-bomb', regex: /:\(\)\s*\{.*:\s*\|\s*:.*&/, reason: 'fork bomb detected' }, + + // HIGH - Significant risk, data loss, security + { level: 'high', id: 'curl-pipe-sh', regex: /\b(curl|wget)\b.+\|\s*(ba)?sh\b/, reason: 'piping URL to shell (RCE risk)' }, + { level: 'high', id: 'git-force-main', regex: /\bgit\s+push\b(?!.+--force-with-lease).+(--force|-f)\b.+\b(main|master)\b/, reason: 'force push to main/master' }, + { level: 'high', id: 'git-reset-hard', regex: /\bgit\s+reset\s+--hard/, reason: 'git reset --hard loses uncommitted work' }, + { level: 'high', id: 'git-clean-f', regex: /\bgit\s+clean\s+(-\w*f|-f)/, reason: 'git clean -f deletes untracked files' }, + { level: 'high', id: 'chmod-777', regex: /\bchmod\b.+\b777\b/, reason: 'chmod 777 is a security risk' }, + { level: 'high', id: 'docker-vol-rm', regex: /\bdocker\s+volume\s+(rm|prune)/, reason: 'docker volume deletion loses data' }, + + // STRICT - Cautionary, context-dependent + { level: 'strict', id: 'git-force-any', regex: /\bgit\s+push\b(?!.+--force-with-lease).+(--force|-f)\b/, reason: 'force push (use --force-with-lease)' }, + { level: 'strict', id: 'git-checkout-dot', regex: /\bgit\s+checkout\s+\./, reason: 'git checkout . discards changes' }, + { level: 'strict', id: 'sudo-rm', regex: /\bsudo\s+rm\b/, reason: 'sudo rm has elevated privileges' }, + { level: 'strict', id: 'docker-prune', regex: /\bdocker\s+(system|image)\s+prune/, reason: 'docker prune removes images' }, + { level: 'strict', id: 'crontab-r', regex: /\bcrontab\s+-r/, reason: 'removes all cron jobs' }, +]; + +const LEVELS = { critical: 1, high: 2, strict: 3 }; +const EMOJIS = { critical: '🚨', high: 'β›”', strict: '⚠️' }; +const LOG_DIR = path.join(process.env.HOME, '.claude', 'hooks-logs'); + +function log(data) { + try { + if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + const file = path.join(LOG_DIR, `${new Date().toISOString().slice(0, 10)}.jsonl`); + fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...data }) + '\n'); + } catch {} +} + +function checkCommand(cmd, safetyLevel = SAFETY_LEVEL) { + const threshold = LEVELS[safetyLevel] || 2; + for (const p of PATTERNS) { + if (LEVELS[p.level] <= threshold && p.regex.test(cmd)) { + return { blocked: true, pattern: p }; + } + } + return { blocked: false, pattern: null }; +} + +async function main() { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + + try { + const data = JSON.parse(input); + const { tool_name, tool_input, session_id, cwd, permission_mode } = data; + if (tool_name !== 'Bash') return console.log('{}'); + + const cmd = tool_input?.command || ''; + const result = checkCommand(cmd); + + if (result.blocked) { + const p = result.pattern; + const shouldAsk = ASK[p.level] === true; + const decision = shouldAsk ? 'ask' : 'deny'; + log({ level: shouldAsk ? 'ASK' : 'BLOCKED', id: p.id, priority: p.level, decision, cmd, session_id, cwd, permission_mode }); + return console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: decision, + permissionDecisionReason: `${EMOJIS[p.level]} [${p.id}] ${p.reason}` + } + })); + } + console.log('{}'); + } catch (e) { + log({ level: 'ERROR', error: e.message }); + console.log('{}'); + } +} + +if (require.main === module) { + main(); +} else { + module.exports = { PATTERNS, LEVELS, SAFETY_LEVEL, ASK, checkCommand }; +} diff --git a/plugins/guard-pack/lib/case-insensitive-guard.js b/plugins/guard-pack/lib/case-insensitive-guard.js new file mode 100644 index 0000000..72ae3ff --- /dev/null +++ b/plugins/guard-pack/lib/case-insensitive-guard.js @@ -0,0 +1,520 @@ +#!/usr/bin/env node +/** + * Case-Insensitive Filesystem Guard - PreToolUse Hook for Bash + * On case-insensitive filesystems (APFS default, exFAT, NTFS), `Content` and + * `content` are the same path: so `rm -rf content` silently destroys + * `Content` (see anthropics/claude-code#37875). This hook resolves the real + * target directory of destructive commands and denies the ones that would hit + * a case-variant of what was typed. Logs to: ~/.claude/hooks-logs/ + * + * Idea credit: @yurukusa (https://github.com/karanb192/claude-code-hooks/pull/8) + * + * Covered commands: rm, rmdir, mv, mkdir, touch, and `find … -delete` + * (also `find … -exec rm/unlink`). Directory context is modeled as a set of + * candidate cwds plus the last command's exit status: `cd`/`pushd` move it + * (honoring whether the target directory exists: including directories a + * `mkdir` earlier in the same command line would create), `popd` restores the + * matching `pushd`'s directory, a subshell `( … )` restores the outer cwd at + * `)`, and connectors gate execution (`&&` runs only after success, `||` only + * after failure, `;`/newline/`|`/`&` regardless: with `cd` in a background + * (`&`) or pipeline (`|`) position not moving the parent shell). Heredoc + * bodies are stripped before analysis, and wrapper prefixes (`nohup`, `exec`, + * `command`, `env`, `sudo`, …) are peeled off. + * + * SAFETY_LEVEL: 'critical' | 'high' | 'strict' (default 'high') + * critical - recursive `rm -r/-rf` (and `find -delete`) onto a case-variant + * high - + non-recursive deletes that really remove something + * (`rm` of a case-variant FILE, `rm -d`/`rmdir` of a case-variant + * dir, `mv` overwriting a case-variant FILE) + * strict - + mkdir/touch onto an existing case-variant (usually a no-op, + * but often a sign the agent has the wrong name in mind) + * Override via HOOK_SAFETY_LEVEL=critical|high|strict: prefer the env var over + * editing this file (plugin updates overwrite it). Anything else falls back to + * the default. + * + * Ask mode (opt-in, per level): set HOOK_ASK_CRITICAL / HOOK_ASK_HIGH / + * HOOK_ASK_STRICT to the literal string "true" to prompt instead of deny. + * + * Design notes (what is deliberately NOT flagged): + * - Glob targets (`rm -rf Cont*`): the shell expands globs against real + * directory entries with case-SENSITIVE matching (bash default), so a + * miscased glob matches nothing and cannot case-collide. (Limitation: + * `shopt -s nocaseglob` defeats this; not modeled.) + * - `$VAR`, `$(…)`, backticks: not statically resolvable β†’ conservatively + * allowed rather than guessed at. + * - Exact-case targets: `rm -rf Content` when `Content` exists as typed is an + * intentional delete, not a collision. + * - A case-only rename (`mv readme.md README.md`) is the CANONICAL fix for a + * miscapitalized name: allowed, not blocked. `mv -t DIR`/`--target-directory` + * moves INTO a directory (contents preserved): allowed. + * - Plain `rm` (no -r/-d) of a case-variant DIRECTORY: `rm` refuses to remove + * a directory, so nothing is destroyed β†’ allowed. + * - Heredoc bodies (`cat < key in process.env ? process.env[key] === 'true' : fallback; +const ASK = { + critical: envBool('HOOK_ASK_CRITICAL', false), + high: envBool('HOOK_ASK_HIGH', false), + strict: envBool('HOOK_ASK_STRICT', false), +}; + +const LOG_DIR = path.join(process.env.HOME || os.homedir(), '.claude', 'hooks-logs'); + +function log(entry) { + try { + fs.mkdirSync(LOG_DIR, { recursive: true }); + const file = path.join(LOG_DIR, `${new Date().toISOString().slice(0, 10)}.jsonl`); + fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), hook: 'case-insensitive-guard', ...entry }) + '\n'); + } catch { /* logging must never break the hook */ } +} + +// ─── shell-aware parsing ───────────────────────────────────────────────────── + +// Remove heredoc bodies (`<]/.test(command[j])) { delim += command[j]; j++; } + if (q && command[j] === q) j++; + if (delim && !/^\d+$/.test(delim)) { pending.push(delim); out += ' '; i = j; continue; } + out += c; i++; continue; + } + if (c === '\n' && pending.length) { + out += '\n'; i++; + while (pending.length && i < command.length) { + let eol = command.indexOf('\n', i); + if (eol === -1) eol = command.length; + if (command.slice(i, eol).replace(/^\t+/, '') === pending[0]) pending.shift(); + i = eol + 1; + } + continue; + } + out += c; i++; + } + return out; +} + +// Split a command line into { text, connector } segments, where connector is +// the operator that PRECEDES the segment (null for the first). Operators inside +// single/double quotes are ignored. A single `&` (background) is a separator +// too: the command after it still runs; `&>`, `>&`, `<&` redirects are not. +function splitSegments(command) { + const segments = []; + let cur = '', quote = null, connector = null; + const push = (nextConnector) => { + const text = cur.trim(); + if (text) segments.push({ text, connector }); + cur = ''; + connector = nextConnector; + }; + for (let i = 0; i < command.length; i++) { + const c = command[i]; + if (quote) { + cur += c; + if (c === quote && command[i - 1] !== '\\') quote = null; + continue; + } + if (c === "'" || c === '"') { quote = c; cur += c; continue; } + if (c === '&' && command[i + 1] === '&') { push('&&'); i++; continue; } + if (c === '&' && command[i + 1] !== '>' && command[i - 1] !== '>' && command[i - 1] !== '<') { push('&'); continue; } + if (c === '|' && command[i + 1] === '|') { push('||'); i++; continue; } + if (c === ';') { push(';'); continue; } + if (c === '|') { push('|'); continue; } + if (c === '\n') { push('\n'); continue; } + cur += c; + } + push(null); + return segments; +} + +// Tokenize one segment on whitespace, respecting quotes. Each token records +// whether any part was quoted (quoted globs are literal to the shell). Unquoted +// `(` / `)` are shell operators and become standalone tokens even unspaced. +function tokenize(segment) { + const tokens = []; + let text = '', quoted = false, quote = null, started = false; + const push = () => { if (started) tokens.push({ text, quoted }); text = ''; quoted = false; started = false; }; + for (let i = 0; i < segment.length; i++) { + const c = segment[i]; + if (quote) { + if (c === quote) { quote = null; } else { text += c; } + continue; + } + if (c === "'" || c === '"') { quote = c; quoted = true; started = true; continue; } + if (c === '(' || c === ')') { push(); tokens.push({ text: c, quoted: false }); continue; } + if (/\s/.test(c)) { push(); continue; } + if (c === '\\' && i + 1 < segment.length) { text += segment[i + 1]; started = true; i++; continue; } + text += c; started = true; + } + push(); + return tokens; +} + +const SHELL_KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', 'do', 'done', 'case', 'esac', 'function', '{', '}']); +// Wrappers that just run their argument as the real command. +const PREFIX_CMDS = new Set(['command', 'builtin', 'exec', 'time', 'nohup', 'env', 'sudo']); + +// Strip subshell parens, leading env assignments (FOO=bar) and `sudo`. +function commandTokens(tokens) { + let toks = tokens.filter(t => t.text !== '(' && t.text !== ')'); + let i = 0; + while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i].text)) i++; + if (i < toks.length && toks[i].text === 'sudo') { + i++; + while (i < toks.length && toks[i].text.startsWith('-')) i++; + } + return toks.slice(i); +} + +function hasUnresolvable(text) { return /[$`]/.test(text); } +function hasGlob(tok) { return !tok.quoted && /[*?[]/.test(tok.text); } + +function resolveTarget(text, cwd, quoted = false) { + if (hasUnresolvable(text)) return null; + let t = text; + if (!quoted) { // a quoted ~ is a literal character to the shell + if (t === '~') t = os.homedir(); + else if (t.startsWith('~/')) t = path.join(os.homedir(), t.slice(2)); + } + if (!path.isAbsolute(t) && !cwd) return null; + return path.resolve(cwd || '/', t); +} + +// ─── collision analysis ────────────────────────────────────────────────────── + +// Real filesystem operations, injectable for hermetic cross-platform tests. +const realFsx = { + listDir(dir) { try { return fs.readdirSync(dir); } catch { return null; } }, + pathExists(p) { try { return fs.existsSync(p); } catch { return false; } }, + isDirectory(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }, +}; + +// A collision exists when the typed basename is NOT an on-disk entry of the +// parent, yet a differently-cased entry is: and the typed path still exists +// (proving the volume folds case, no probe file needed). +function findCollision(resolved, fsx) { + const parent = path.dirname(resolved); + const base = path.basename(resolved); + if (!base || base === '.' || base === '..') return null; + const entries = fsx.listDir(parent); + if (!entries || entries.includes(base)) return null; + const variant = entries.find(e => e.toLowerCase() === base.toLowerCase()); + if (!variant) return null; + if (!fsx.pathExists(resolved)) return null; // case-sensitive volume: harmless miss + const isDir = fsx.isDirectory(path.join(parent, variant)); + return { typed: base, actual: variant, parent, actualIsDir: isDir }; +} + +const RECURSIVE_FLAG = (f) => /^-[A-Za-z]*[rR]/.test(f) || f === '--recursive'; +const DIR_FLAG = (f) => /^-[A-Za-z]*d/.test(f) || f === '--dir'; + +// Split flags from operands for a token list (POSIX `--` ends option parsing). +function splitArgs(tokens) { + const flags = [], operands = []; + let sawDoubleDash = false; + for (const t of tokens) { + if (!sawDoubleDash && t.text === '--' && !t.quoted) { sawDoubleDash = true; continue; } + if (!sawDoubleDash && t.text.startsWith('-') && !t.quoted && t.text !== '-') { flags.push(t.text); continue; } + operands.push(t); + } + return { flags, operands }; +} + +// Analyze one full command string. Returns the first destructive collision: +// { level, cmd, typed, actual, parent }: or null when nothing provable. +// +// State model: `shell` is the set of candidate cwds the shell could be in +// (null = unknown), `lastStatus` is the last command's exit status +// ('ok' | 'fail' | 'unknown') and gates `&&` / `||` segments. `dirStack` +// mirrors pushd/popd; `subStack` snapshots the cwd at `(` and restores it at +// `)`; `createdDirs` remembers `mkdir` targets from earlier in the same line +// so a following `cd` into a not-yet-existing directory is still tracked. +function analyzeCommand(command, cwd, fsx = realFsx) { + if (typeof command !== 'string' || !command.trim()) return null; + let shell = cwd && path.isAbsolute(cwd) ? [cwd] : null; + let lastStatus = 'ok'; + const dirStack = [], subStack = [], createdDirs = new Set(); + + // Merge candidate sets; null means "no further information", so the known + // side wins (its candidates still get checked: fail-closed). + const union = (a, b) => { + if (a === null) return b; + if (b === null) return a; + return [...new Set([...a, ...b])]; + }; + + const segs = splitSegments(stripHeredocs(command)); + for (let si = 0; si < segs.length; si++) { + const { text, connector } = segs[si]; + const nextConn = si + 1 < segs.length ? segs[si + 1].connector : null; + + const runs = connector === '&&' ? (lastStatus === 'ok' ? true : lastStatus === 'fail' ? false : 'maybe') + : connector === '||' ? (lastStatus === 'fail' ? true : lastStatus === 'ok' ? false : 'maybe') + : true; + if (runs === false) continue; // this segment cannot execute + + if (/\$\(|`/.test(text)) { lastStatus = 'unknown'; continue; } // command substitution: not resolvable + + const raw = tokenize(text); + let lo = 0, hi = raw.length; + while (lo < hi && raw[lo].text === '(') { subStack.push(shell); lo++; } + let closers = 0; + while (hi > lo && raw[hi - 1].text === ')') { closers++; hi--; } + const closeSubshells = () => { + while (closers-- > 0) { + if (subStack.length) shell = subStack.pop(); // `( … )` never moves the parent + lastStatus = 'unknown'; + } + }; + + let tokens = commandTokens(raw.slice(lo, hi)); + let negated = false; + for (let guard = 0; guard < raw.length && tokens.length; guard++) { + const t = tokens[0].text; + if (SHELL_KEYWORDS.has(t) || t === '!') { + if (t === '!') negated = true; + tokens = tokens.slice(1); + } else if (PREFIX_CMDS.has(t)) { + tokens = tokens.slice(1); + while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0].text)) tokens = tokens.slice(1); + while (tokens.length && tokens[0].text.startsWith('-')) tokens = tokens.slice(1); + } else break; + } + if (!tokens.length) { closeSubshells(); continue; } + const cmd = path.basename(tokens[0].text); + + // A `cd` followed by `&` (background) or `|` (pipeline) runs in a subshell + // and never moves the parent: keep the old cwd, add the new fail-closed. + const detached = nextConn === '&' || nextConn === '|'; + + if (cmd === 'cd' || cmd === 'pushd') { + const before = shell; + const target = tokens[1]; + let afters = null, exists; + if (cmd === 'cd' && !target) { afters = [os.homedir()]; exists = true; } + else if (!target || hasUnresolvable(target.text) || target.text === '-') { afters = null; exists = undefined; } + else { + const dirs = shell === null ? [null] : shell; + afters = [...new Set(dirs.map(d => resolveTarget(target.text, d, target.quoted)).filter(Boolean))]; + if (!afters.length) afters = null; + if (afters === null) exists = undefined; + else { + const ex = afters.map(a => fsx.isDirectory(a) ? true : createdDirs.has(a) ? undefined : false); + exists = ex.every(v => v === true) ? true : ex.every(v => v === false) ? false : undefined; + } + } + if (cmd === 'pushd') dirStack.push(before); + let newShell, newStatus; + if (afters === null) { newShell = null; newStatus = 'unknown'; } + else if (exists === true) { newShell = afters; newStatus = 'ok'; } + else if (exists === false) { newShell = before; newStatus = 'fail'; } + else { newShell = union(before, afters); newStatus = 'unknown'; } + if (detached) { shell = union(before, newShell); lastStatus = 'ok'; } + else if (runs === 'maybe') { shell = union(before, newShell); lastStatus = 'unknown'; } + else { shell = newShell; lastStatus = negated ? 'unknown' : newStatus; } + closeSubshells(); + continue; + } + if (cmd === 'popd') { + if (dirStack.length) { + const popped = dirStack.pop(); + shell = (runs === true && !detached) ? popped : union(popped, shell); + } // popd on an empty stack fails: cwd unchanged + lastStatus = 'unknown'; + closeSubshells(); + continue; + } + + if (!['rm', 'rmdir', 'mv', 'mkdir', 'touch', 'find'].includes(cmd)) { + lastStatus = 'unknown'; + closeSubshells(); + continue; + } + + const dirs = shell === null ? [null] : shell; + const check = (tok) => { + if (hasGlob(tok) || hasUnresolvable(tok.text)) return null; + for (const d of dirs) { + const resolved = resolveTarget(tok.text, d, tok.quoted); + if (!resolved) continue; + const hit = findCollision(resolved, fsx); + if (hit) return hit; + } + return null; + }; + + const { flags, operands } = splitArgs(tokens.slice(1)); + + if (cmd === 'rm') { + const recursive = flags.some(RECURSIVE_FLAG); + const removesDirs = recursive || flags.some(DIR_FLAG); + for (const tok of operands) { + const hit = check(tok); + if (!hit) continue; + // Plain rm cannot remove a directory, so a dir-only collision is a no-op. + if (hit.actualIsDir && !removesDirs) continue; + return { level: recursive ? 'critical' : 'high', cmd: recursive ? 'rm -r' : 'rm', ...hit }; + } + } else if (cmd === 'rmdir') { + for (const tok of operands) { + const hit = check(tok); + if (hit) return { level: 'high', cmd: 'rmdir', ...hit }; + } + } else if (cmd === 'mv' && operands.length >= 2) { + // `mv -t DIR` / `--target-directory=DIR` moves INTO a directory + // (contents preserved) and its last operand is a SOURCE: skip. + const intoDir = flags.some(f => /^-[A-Za-z]*t$/.test(f) || f.startsWith('--target-directory')); + if (!intoDir) { + const dest = operands[operands.length - 1]; + // A case-only self-rename (source folds to dest) is the intended fix. + const selfRename = operands.slice(0, -1).some((src) => { + for (const d of dirs) { + const s = resolveTarget(src.text, d, src.quoted), t = resolveTarget(dest.text, d, dest.quoted); + if (s && t && path.dirname(s) === path.dirname(t) && path.basename(s).toLowerCase() === path.basename(t).toLowerCase()) return true; + } + return false; + }); + if (!selfRename) { + const hit = check(dest); + // Overwriting a case-variant FILE clobbers data; moving INTO a + // case-variant directory preserves contents. + if (hit && !hit.actualIsDir) return { level: 'high', cmd: 'mv', ...hit }; + } + } + } else if (cmd === 'mkdir' || cmd === 'touch') { + for (const tok of operands) { + const hit = check(tok); + if (hit) return { level: 'strict', cmd, ...hit }; + } + if (cmd === 'mkdir') { + // Remember what this line creates so a later `cd` into it is tracked. + for (const tok of operands) for (const d of dirs) { + const r = resolveTarget(tok.text, d, tok.quoted); + if (r) createdDirs.add(r); + } + } + } else if (cmd === 'find') { + // `find `: paths are the operands before the first + // predicate (a token starting with '-'). Only destructive if the + // expression deletes. + const rest = tokens.slice(1); + const predicateIdx = rest.findIndex(t => t.text.startsWith('-')); + const paths = (predicateIdx === -1 ? rest : rest.slice(0, predicateIdx)); + const expr = predicateIdx === -1 ? [] : rest.slice(predicateIdx); + const deletes = expr.some((t, i) => + t.text === '-delete' || + ((t.text === '-exec' || t.text === '-execdir') && /^(rm|unlink)$/.test(expr[i + 1]?.text || ''))); + if (deletes) { + for (const tok of paths) { + const hit = check(tok); + if (hit) return { level: 'critical', cmd: 'find -delete', ...hit }; + } + } + } + lastStatus = 'unknown'; + closeSubshells(); + } + return null; +} + +function checkCommand(command, cwd, safetyLevel = SAFETY_LEVEL, fsx = realFsx) { + const hit = analyzeCommand(command, cwd, fsx); + if (!hit) return { blocked: false }; + if (LEVELS[hit.level] > LEVELS[safetyLevel]) return { blocked: false }; + return { blocked: true, hit }; +} + +// ─── hook entrypoint ───────────────────────────────────────────────────────── + +async function main() { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + + let data; + try { + data = JSON.parse(input); + } catch { + return console.log('{}'); + } + if (data === null || typeof data !== 'object') data = {}; + + const { tool_name, tool_input, session_id, cwd, permission_mode } = data; + if (tool_name !== 'Bash' || !tool_input?.command) return console.log('{}'); + + const result = checkCommand(tool_input.command, cwd); + if (!result.blocked) return console.log('{}'); + + const { hit } = result; + const shouldAsk = ASK[hit.level] === true; + const decision = shouldAsk ? 'ask' : 'deny'; + log({ level: shouldAsk ? 'ASK' : 'BLOCKED', id: 'case-collision', priority: hit.level, decision, cmd: tool_input.command, typed: hit.typed, actual: hit.actual, session_id, cwd, permission_mode }); + return console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: decision, + permissionDecisionReason: `${EMOJIS[hit.level]} [case-collision] ${hit.cmd} targets '${hit.typed}' but this case-insensitive directory contains '${hit.actual}': same path on disk, so the differently-cased entry would be hit`, + }, + })); +} + +if (require.main === module) { + main(); +} else { + module.exports = { SAFETY_LEVEL, LEVELS, ASK, stripHeredocs, splitSegments, tokenize, commandTokens, resolveTarget, findCollision, analyzeCommand, checkCommand, realFsx }; +} diff --git a/plugins/guard-pack/lib/config-guard.js b/plugins/guard-pack/lib/config-guard.js new file mode 100644 index 0000000..f958b40 --- /dev/null +++ b/plugins/guard-pack/lib/config-guard.js @@ -0,0 +1,261 @@ +#!/usr/bin/env node +/** + * Config Guard - PreToolUse Hook for Bash|Edit|MultiEdit|Write + * Who guards the guards: blocks the agent from tampering with its own + * guardrail configuration - settings files that wire hooks and permissions, + * the hook scripts themselves, hook manifests, MCP and plugin config. + * Reads always pass (agents legitimately read settings); only writes, + * in-place edits, moves, copies onto, and deletes are stopped. + * Logs to: ~/.claude/hooks-logs/ + * + * Why this hook exists: the Aug 2026 CHAINDROP npm worm hid its payload in + * .claude/settings.json, and CVE-2026-25725 let sandboxed code persist by + * injecting a hooks entry into a settings.json that did not exist yet + * (which is why CREATING a protected file counts as mutation here). An + * agent that can rewrite its own hook config can switch every other + * guardrail off; this hook closes that loop. Pair it with config-watch.js + * (ConfigChange event) to also catch changes made from outside the agent. + * + * SAFETY_LEVEL: 'critical' | 'high' | 'strict' (default 'high'; override via + * the HOOK_SAFETY_LEVEL env var - plugin updates overwrite installed files, + * so edit env, not this file; invalid values fall back to the default) + * critical - the enforcement chain: .claude/settings.json, + * .claude/settings.local.json, managed-settings.json, + * anything under .claude/hooks/, hooks.json manifests + * high - + config supply chain: .mcp.json, .claude-plugin/, + * `claude config set|add|remove`, `claude mcp add|remove`, and + * `claude plugin install|uninstall|enable|disable|marketplace` + * strict - + instruction files: CLAUDE.md, CLAUDE.local.md, + * .claude/rules/, .claude/agents/, .claude/commands/ + * + * Escape hatch: set CONFIG_GUARD_ALLOW to the literal string "true" for an + * intentional, human-approved config change (e.g. prefix a single hook + * command with it in settings.json, or export it for one shell call). + * Ask mode (opt-in, per level): set HOOK_ASK_CRITICAL / HOOK_ASK_HIGH / + * HOOK_ASK_STRICT to "true" to prompt the user instead of denying outright, + * so intentional edits degrade to a question instead of a hard wall. + * + * Known limits (deliberately NOT a full shell parser, same trade as + * protect-tests): a mutation verb and a protected path in the same Bash + * command block it even if the verb technically targets another argument; + * interpreter one-liners (python -c "open(...).write(...)"), git + * checkout/restore of a config path, chmod, and paths built from variables + * ("$DIR/settings.json") are not caught. Edit/MultiEdit/Write paths are + * resolved through symlinks before checking (a write through + * `ln -s ~/.claude /tmp/x` is still caught), but shell command strings are + * matched as text, so a Bash redirect through a symlinked directory is not. + * PreToolUse only sees the agent's own tool calls; out-of-band writes are + * config-watch.js territory. + * + * Setup (plugin, recommended): + * /plugin marketplace add karanb192/claude-code-hooks + * /plugin install config-guard@claude-code-hooks + * The plugin's hooks/hooks.json registers this script automatically. + * + * Setup (classic): copy this file somewhere stable and register it in + * .claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "Bash|Edit|MultiEdit|Write", + * "hooks": [{ "type": "command", "command": "node /path/to/config-guard.js" }] + * }] + * } + * } + */ + +const fs = require('fs'); +const path = require('path'); + +// Env-overridable because plugin updates overwrite installed files; an +// invalid HOOK_SAFETY_LEVEL falls back to the default 'high'. +const VALID_SAFETY_LEVELS = ['critical', 'high', 'strict']; +const SAFETY_LEVEL = VALID_SAFETY_LEVELS.includes(process.env.HOOK_SAFETY_LEVEL) + ? process.env.HOOK_SAFETY_LEVEL + : 'high'; + +// Ask mode per level: if true, prompts the user instead of blocking outright. +// Env overrides: HOOK_ASK_CRITICAL, HOOK_ASK_HIGH, HOOK_ASK_STRICT +const envBool = (key, fallback) => key in process.env ? process.env[key] === 'true' : fallback; +const ASK = { + critical: envBool('HOOK_ASK_CRITICAL', false), + high: envBool('HOOK_ASK_HIGH', false), + strict: envBool('HOOK_ASK_STRICT', false), +}; + +// Escape hatch for intentional, human-approved config edits (literal "true"). +const ALLOW_OVERRIDE = envBool('CONFIG_GUARD_ALLOW', false); + +// Guardrail config targets as file paths (Edit / MultiEdit / Write file_path). +const PROTECTED_PATHS = [ + // CRITICAL - the enforcement chain itself + { level: 'critical', id: 'settings-file', regex: /(^|\/)\.claude\/settings(\.local)?\.json$/i, reason: 'Claude Code settings wire hooks and permissions' }, + { level: 'critical', id: 'managed-settings', regex: /(^|\/)managed-settings\.json$/i, reason: 'managed policy settings' }, + { level: 'critical', id: 'hook-script', regex: /(^|\/)\.claude\/hooks(\/|$)/i, reason: 'hook scripts are the guardrails themselves' }, + { level: 'critical', id: 'hooks-manifest', regex: /(^|\/)hooks\.json$/i, reason: 'hooks.json manifests register hooks' }, + + // HIGH - config supply chain + { level: 'high', id: 'mcp-config', regex: /(^|\/)\.mcp\.json$/i, reason: '.mcp.json adds MCP servers (new tools)' }, + { level: 'high', id: 'plugin-manifest', regex: /(^|\/)\.claude-plugin(\/|$)/i, reason: 'plugin manifests install hooks and skills' }, + + // STRICT - instruction files that steer the agent + { level: 'strict', id: 'claude-md', regex: /(^|\/)CLAUDE(\.local)?\.md$/i, reason: 'CLAUDE.md instructions steer the agent' }, + { level: 'strict', id: 'rules-dir', regex: /(^|\/)\.claude\/(rules|agents|commands)\//i, reason: 'rules, agents, and commands steer the agent' }, +]; + +// The same targets as they appear as tokens inside a shell command. Not +// end-anchored: a token ends at whitespace or a quote, and suffixed forms +// (settings.json.bak) are config-adjacent enough to guard too. +const BASH_TOKENS = [ + { level: 'critical', id: 'settings-file', regex: /\.claude\/settings(\.local)?\.json/i, reason: 'Claude Code settings wire hooks and permissions' }, + { level: 'critical', id: 'managed-settings', regex: /managed-settings\.json/i, reason: 'managed policy settings' }, + { level: 'critical', id: 'hook-script', regex: /\.claude\/hooks([/\s'"]|$)/i, reason: 'hook scripts are the guardrails themselves' }, + { level: 'critical', id: 'hooks-manifest', regex: /(^|[\s'"/=])hooks\.json/i, reason: 'hooks.json manifests register hooks' }, + { level: 'high', id: 'mcp-config', regex: /\.mcp\.json/i, reason: '.mcp.json adds MCP servers (new tools)' }, + { level: 'high', id: 'plugin-manifest', regex: /\.claude-plugin([/\s'"]|$)/i, reason: 'plugin manifests install hooks and skills' }, + { level: 'strict', id: 'claude-md', regex: /(^|[\s'"/=])CLAUDE(\.local)?\.md/i, reason: 'CLAUDE.md instructions steer the agent' }, + { level: 'strict', id: 'rules-dir', regex: /\.claude\/(rules|agents|commands)([/\s'"]|$)/i, reason: 'rules, agents, and commands steer the agent' }, +]; + +// Mutation forms in shell commands. Reads (cat, jq, grep, ls, diff, node) pass. +// All case-insensitive: on the case-insensitive filesystems macOS and Windows +// default to, `RM` and `SED` resolve to the same binaries. +const DELETE_VERB = /(\brm\b|\bunlink\b|\bshred\b|\btrash\b|\bgit\s+rm\b)/i; +const MOVE_COPY_VERB = /\b(mv|cp|rsync|install|ln)\b/i; +const WRITE_VERB = /\b(tee|truncate|dd)\b/i; +const INPLACE_EDIT = /\b(sed|gsed)\s+[^|;&]*-i\b|\bperl\s+[^|;&]*-\w*i\b|\bgawk\s+[^|;&]*-i\s*inplace\b/i; + +// CLI commands that rewrite agent config without naming a settings path. +// `claude plugin install` registers arbitrary hooks and skills, so it is a +// config write in everything but name; list/get/read forms stay allowed. +const CLAUDE_CLI_WRITE = /\bclaude\s+(config\s+(set|add|remove|rm)|mcp\s+(add|add-json|add-from-claude-desktop|remove|rm)|plugin\s+(install|uninstall|enable|disable|update|marketplace\s+(add|remove|rm|update)))\b/i; + +const LEVELS = { critical: 1, high: 2, strict: 3 }; +const EMOJIS = { critical: 'πŸ”’', high: 'πŸ›‘οΈ', strict: '⚠️' }; +const LOG_DIR = path.join(process.env.HOME || '/tmp', '.claude', 'hooks-logs'); + +function log(data) { + try { + if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + const file = path.join(LOG_DIR, `${new Date().toISOString().slice(0, 10)}.jsonl`); + fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), hook: 'config-guard', ...data }) + '\n'); + } catch {} +} + +// True when the command redirects output into a protected token: +// "> path", ">> path", "2> path", ">| path" (with optional quoting/prefix). +function redirectsInto(cmd, tokenRegex) { + const re = new RegExp(`(^|[^<>])>{1,2}\\|?\\s*['"]?[^'"\\s;|&]*(${tokenRegex.source})`, 'i'); + return re.test(cmd); +} + +// The typed path plus its symlink-resolved form: a write through +// `ln -s ~/.claude /tmp/x` must be judged by where it really lands. When the +// file does not exist yet (the CVE vector), resolve the parent directory. +function resolveCandidates(filePath) { + const candidates = [filePath]; + try { + candidates.push(fs.realpathSync(filePath)); + } catch { + try { + candidates.push(path.join(fs.realpathSync(path.dirname(filePath)), path.basename(filePath))); + } catch { /* parent missing too: nothing on disk to resolve */ } + } + return [...new Set(candidates)]; +} + +// Check for Edit/MultiEdit/Write file paths - unit-testable (touches the +// filesystem only to resolve symlinks; unresolvable paths check as typed). +function checkFilePath(filePath, safetyLevel = SAFETY_LEVEL) { + if (!filePath) return { blocked: false }; + const threshold = LEVELS[safetyLevel] || 2; + for (const candidate of resolveCandidates(filePath)) { + for (const p of PROTECTED_PATHS) { + if (LEVELS[p.level] <= threshold && p.regex.test(candidate)) { + return { blocked: true, id: p.id, level: p.level, reason: p.reason }; + } + } + } + return { blocked: false }; +} + +// Pure check for Bash commands - unit-testable. +function checkBashCommand(cmd, safetyLevel = SAFETY_LEVEL) { + if (!cmd) return { blocked: false }; + const threshold = LEVELS[safetyLevel] || 2; + + if (LEVELS.high <= threshold && CLAUDE_CLI_WRITE.test(cmd)) { + return { blocked: true, id: 'claude-cli-config', level: 'high', reason: 'claude config/mcp CLI write rewrites agent config' }; + } + + for (const t of BASH_TOKENS) { + if (LEVELS[t.level] > threshold || !t.regex.test(cmd)) continue; + if (redirectsInto(cmd, t.regex)) + return { blocked: true, id: t.id, level: t.level, reason: `shell redirect into protected config: ${t.reason}` }; + if (INPLACE_EDIT.test(cmd)) + return { blocked: true, id: t.id, level: t.level, reason: `in-place edit of protected config: ${t.reason}` }; + if (DELETE_VERB.test(cmd)) + return { blocked: true, id: t.id, level: t.level, reason: `deleting protected config: ${t.reason}` }; + if (MOVE_COPY_VERB.test(cmd)) + return { blocked: true, id: t.id, level: t.level, reason: `moving/copying/linking a protected config path: ${t.reason}` }; + if (WRITE_VERB.test(cmd)) + return { blocked: true, id: t.id, level: t.level, reason: `writing to protected config: ${t.reason}` }; + } + return { blocked: false }; +} + +// Returns { blocked, id, level, reason } - pure, so it is unit-testable. +// Read (and any unrecognized tool) always passes: reading config is legitimate. +function checkTool(toolName, toolInput = {}, safetyLevel = SAFETY_LEVEL) { + if (toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'Write') { + return checkFilePath(toolInput.file_path || '', safetyLevel); + } + if (toolName === 'Bash') { + return checkBashCommand(toolInput.command || '', safetyLevel); + } + return { blocked: false }; +} + +async function main() { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + + try { + const data = JSON.parse(input); + const { tool_name, tool_input, session_id, cwd, permission_mode } = data; + + if (ALLOW_OVERRIDE) { + log({ level: 'ALLOW_OVERRIDE', tool: tool_name, session_id, cwd, permission_mode }); + return console.log('{}'); + } + + const result = checkTool(tool_name, tool_input || {}); + + if (result.blocked) { + const shouldAsk = ASK[result.level] === true; + const decision = shouldAsk ? 'ask' : 'deny'; + const target = tool_input?.file_path || tool_input?.command?.slice(0, 100); + log({ level: shouldAsk ? 'ASK' : 'BLOCKED', id: result.id, priority: result.level, decision, tool: tool_name, target, session_id, cwd, permission_mode }); + return console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: decision, + permissionDecisionReason: `${EMOJIS[result.level]} [${result.id}] ${result.reason}. Guardrail config is protected; if this change is intentional and human-approved, set CONFIG_GUARD_ALLOW=true for this call or edit the file yourself.` + } + })); + } + console.log('{}'); + } catch (e) { + log({ level: 'ERROR', error: e.message }); + console.log('{}'); + } +} + +if (require.main === module) { + main(); +} else { + module.exports = { + PROTECTED_PATHS, BASH_TOKENS, LEVELS, SAFETY_LEVEL, ASK, + checkTool, checkFilePath, checkBashCommand, + }; +} diff --git a/plugins/guard-pack/lib/git-safety.js b/plugins/guard-pack/lib/git-safety.js new file mode 100644 index 0000000..84ed3ee --- /dev/null +++ b/plugins/guard-pack/lib/git-safety.js @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * Git Safety - PreToolUse Hook for Bash + * Blocks destructive git and gh CLI operations. Logs to: ~/.claude/hooks-logs/ + * + * SAFETY_LEVEL: 'critical' | 'high' | 'strict' (default 'high') + * Override via the HOOK_SAFETY_LEVEL env var; invalid values fall back to the + * default. Prefer the env var over editing this file: plugin updates + * overwrite installed files. + * critical - no git-safety rules apply (defer entirely to block-dangerous-commands.js) + * high - branch-aware guardrails (commit/merge/rebase/reset/push while on a + * protected branch), protected-branch deletion, direct pushes to + * main/master by name, and destructive gh CLI operations + * strict - + force-push, so this hook is self-sufficient standalone + * + * Composition with block-dangerous-commands.js: + * That hook already blocks force-push (any, and to main/master) and + * `git reset --hard` on any branch. At the default 'high' level this hook adds + * only the complementary coverage above, so the two do not overlap. Raise this + * hook to 'strict' (or leave the sibling out) if you run git-safety on its own. + * + * Setup (plugin, recommended): + * /plugin marketplace add karanb192/claude-code-hooks + * /plugin install git-safety@claude-code-hooks + * + * Classic setup still works: copy the script somewhere stable and register it + * in .claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "Bash", + * "hooks": [{ "type": "command", "command": "node /path/to/git-safety.js" }] + * }] + * } + * } + */ + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_SAFETY_LEVEL = 'high'; +const SAFETY_LEVEL = ['critical', 'high', 'strict'].includes(process.env.HOOK_SAFETY_LEVEL) + ? process.env.HOOK_SAFETY_LEVEL + : DEFAULT_SAFETY_LEVEL; + +const PROTECTED_BRANCHES = ['main', 'master']; + +const PATTERNS = [ + // STRICT - force-push is normally handled by block-dangerous-commands.js. + // Only enforced here at 'strict' so git-safety is self-sufficient standalone. + { level: 'strict', id: 'force-push', regex: /\bgit\s+push\b.*(?:--force(?!-with-lease)|-f)\b/, reason: 'Force-pushing is not allowed' }, + + // HIGH - complementary coverage the sibling hook does not provide + + // Block pushing directly to a protected branch by name + { level: 'high', id: 'push-main', regex: /\bgit\s+push\b.*\bmain\b/, reason: 'Pushing to main is not allowed' }, + { level: 'high', id: 'push-master', regex: /\bgit\s+push\b.*\bmaster\b/, reason: 'Pushing to master is not allowed' }, + + // Block deleting protected branches locally + { level: 'high', id: 'branch-delete-protected', regex: /\bgit\s+branch\s+.*(?:-[dD]|--delete)\s+(?:main|master)\b/, reason: 'Deleting a protected branch is not allowed' }, + + // Block direct changes when on a protected branch + { level: 'high', id: 'commit-on-protected', regex: /\bgit\s+commit\b/, reason: 'Committing directly on {branch} is not allowed', branchOnly: true }, + { level: 'high', id: 'merge-on-protected', regex: /\bgit\s+merge\b/, reason: 'Merging into {branch} is not allowed', branchOnly: true }, + { level: 'high', id: 'rebase-on-protected', regex: /\bgit\s+rebase\b/, reason: 'Rebasing {branch} is not allowed', branchOnly: true }, + { level: 'high', id: 'reset-on-protected', regex: /\bgit\s+reset\b/, reason: 'Resetting {branch} is not allowed', branchOnly: true }, + { level: 'high', id: 'push-on-protected', regex: /\bgit\s+push\b/, reason: 'Pushing from {branch} is not allowed', branchOnly: true }, + + // Block destructive gh CLI operations + { level: 'high', id: 'gh-pr-merge', regex: /\bgh\s+pr\s+merge\b/, reason: 'Merging PRs via gh CLI is not allowed' }, + { level: 'high', id: 'gh-pr-close', regex: /\bgh\s+pr\s+close\b/, reason: 'Closing PRs via gh CLI is not allowed' }, + { level: 'high', id: 'gh-issue-close', regex: /\bgh\s+issue\s+close\b/, reason: 'Closing issues via gh CLI is not allowed' }, + { level: 'high', id: 'gh-release-delete', regex: /\bgh\s+release\s+delete\b/, reason: 'Deleting releases via gh CLI is not allowed' }, + { level: 'high', id: 'gh-repo-delete', regex: /\bgh\s+repo\s+delete\b/, reason: 'Deleting repos via gh CLI is not allowed' }, +]; + +const LEVELS = { critical: 1, high: 2, strict: 3 }; +const EMOJIS = { critical: '🚨', high: 'β›”', strict: '⚠️' }; +const LOG_DIR = path.join(process.env.HOME, '.claude', 'hooks-logs'); + +function log(data) { + try { + if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + const file = path.join(LOG_DIR, `${new Date().toISOString().slice(0, 10)}.jsonl`); + fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...data }) + '\n'); + } catch {} +} + +function getCurrentBranch() { + try { + return execFileSync('git', ['branch', '--show-current'], { encoding: 'utf-8' }).trim(); + } catch { + return ''; + } +} + +function checkCommand(cmd, branch = null, safetyLevel = SAFETY_LEVEL) { + const threshold = LEVELS[safetyLevel] || LEVELS.high; + for (const p of PATTERNS) { + if (LEVELS[p.level] > threshold) continue; + if (!p.regex.test(cmd)) continue; + + if (p.branchOnly) { + if (!branch) branch = getCurrentBranch(); + if (!PROTECTED_BRANCHES.includes(branch)) continue; + } + + const reason = p.reason.replace('{branch}', branch || ''); + return { blocked: true, pattern: p, reason }; + } + + return { blocked: false }; +} + +async function main() { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + + try { + const data = JSON.parse(input); + const { tool_name, tool_input, session_id, cwd, permission_mode } = data; + if (tool_name !== 'Bash') return console.log('{}'); + + const cmd = tool_input?.command || ''; + const result = checkCommand(cmd); + + if (result.blocked) { + const p = result.pattern; + log({ level: 'BLOCKED', id: p.id, priority: p.level, cmd, session_id, cwd, permission_mode }); + return console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: `${EMOJIS[p.level]} [${p.id}] ${result.reason}` + } + })); + } + + console.log('{}'); + } catch (e) { + log({ level: 'ERROR', error: e.message }); + console.log('{}'); + } +} + +if (require.main === module) { + main(); +} else { + module.exports = { PATTERNS, PROTECTED_BRANCHES, LEVELS, SAFETY_LEVEL, checkCommand }; +} diff --git a/plugins/guard-pack/lib/protect-secrets.js b/plugins/guard-pack/lib/protect-secrets.js new file mode 100644 index 0000000..e37bb92 --- /dev/null +++ b/plugins/guard-pack/lib/protect-secrets.js @@ -0,0 +1,241 @@ +#!/usr/bin/env node +/** + * Protect Secrets - PreToolUse Hook for Read|Edit|Write|Bash + * Prevents reading, modifying, or exfiltrating sensitive files. + * Logs to: ~/.claude/hooks-logs/ + * + * SAFETY_LEVEL: 'critical' | 'high' | 'strict' (default: 'high') + * critical - SSH keys, AWS creds, .env files only + * high - + secrets files, env dumps, exfiltration attempts + * strict - + database configs, any config that might contain secrets + * Override via HOOK_SAFETY_LEVEL instead of editing this file (plugin updates + * overwrite installed files). Invalid values fall back to 'high'. + * + * Ask mode (opt-in, per level): set HOOK_ASK_CRITICAL / HOOK_ASK_HIGH / + * HOOK_ASK_STRICT to the literal string "true" in the hook command to have + * that level prompt the user ("ask") instead of blocking outright ("deny"). + * e.g. "command": "HOOK_ASK_STRICT=true node /path/to/protect-secrets.js" + * + * Setup (plugin, recommended): + * /plugin marketplace add karanb192/claude-code-hooks # once per machine + * /plugin install protect-secrets@claude-code-hooks + * The plugin registers this hook automatically; restart Claude Code after install. + * + * Classic setup (copy this script somewhere stable) still works, via + * .claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "Read|Edit|Write|Bash", + * "hooks": [{ "type": "command", "command": "node /path/to/protect-secrets.js" }] + * }] + * } + * } + */ + +const fs = require('fs'); +const path = require('path'); + +// Safety level: override via HOOK_SAFETY_LEVEL ('critical' | 'high' | 'strict'). +// Anything else (or unset) falls back to the default so a typo can never +// silently disable the guard. +const DEFAULT_SAFETY_LEVEL = 'high'; +const SAFETY_LEVEL = ['critical', 'high', 'strict'].includes(process.env.HOOK_SAFETY_LEVEL) + ? process.env.HOOK_SAFETY_LEVEL + : DEFAULT_SAFETY_LEVEL; + +// Ask mode per level: if true, prompts the user instead of blocking outright. +// When ask=true, the hook returns decision "ask" so Claude Code shows the +// reason and lets the user decide. When ask=false (default), the hook denies. +// Env overrides: HOOK_ASK_CRITICAL, HOOK_ASK_HIGH, HOOK_ASK_STRICT +const envBool = (key, fallback) => key in process.env ? process.env[key] === 'true' : fallback; +const ASK = { + critical: envBool('HOOK_ASK_CRITICAL', false), + high: envBool('HOOK_ASK_HIGH', false), + strict: envBool('HOOK_ASK_STRICT', false), +}; + +// Files explicitly safe to access (templates, examples) +const ALLOWLIST = [ + /\.env\.example$/i, /\.env\.sample$/i, /\.env\.template$/i, + /\.env\.schema$/i, /\.env\.defaults$/i, /env\.example$/i, /example\.env$/i, +]; + +// Sensitive file patterns for Read, Edit, Write tools +const SENSITIVE_FILES = [ + // CRITICAL + { level: 'critical', id: 'env-file', regex: /(?:^|\/)\.env(?:\.[^/]*)?$/, reason: '.env file contains secrets' }, + { level: 'critical', id: 'envrc', regex: /(?:^|\/)\.envrc$/, reason: '.envrc (direnv) contains secrets' }, + { level: 'critical', id: 'ssh-private-key', regex: /(?:^|\/)\.ssh\/id_[^/]+$/, reason: 'SSH private key' }, + { level: 'critical', id: 'ssh-private-key-2', regex: /(?:^|\/)(id_rsa|id_ed25519|id_ecdsa|id_dsa)$/, reason: 'SSH private key' }, + { level: 'critical', id: 'ssh-authorized', regex: /(?:^|\/)\.ssh\/authorized_keys$/, reason: 'SSH authorized_keys' }, + { level: 'critical', id: 'aws-credentials', regex: /(?:^|\/)\.aws\/credentials$/, reason: 'AWS credentials file' }, + { level: 'critical', id: 'aws-config', regex: /(?:^|\/)\.aws\/config$/, reason: 'AWS config may contain secrets' }, + { level: 'critical', id: 'kube-config', regex: /(?:^|\/)\.kube\/config$/, reason: 'Kubernetes config contains credentials' }, + { level: 'critical', id: 'pem-key', regex: /\.pem$/i, reason: 'PEM key file' }, + { level: 'critical', id: 'key-file', regex: /\.key$/i, reason: 'Key file' }, + { level: 'critical', id: 'p12-key', regex: /\.(p12|pfx)$/i, reason: 'PKCS12 key file' }, + + // HIGH + { level: 'high', id: 'credentials-json', regex: /(?:^|\/)credentials\.json$/i, reason: 'Credentials file' }, + { level: 'high', id: 'secrets-file', regex: /(?:^|\/)(secrets?|credentials?)\.(json|ya?ml|toml)$/i, reason: 'Secrets configuration file' }, + { level: 'high', id: 'service-account', regex: /service[_-]?account.*\.json$/i, reason: 'GCP service account key' }, + { level: 'high', id: 'gcloud-creds', regex: /(?:^|\/)\.config\/gcloud\/.*(credentials|tokens)/i, reason: 'GCloud credentials' }, + { level: 'high', id: 'azure-creds', regex: /(?:^|\/)\.azure\/(credentials|accessTokens)/i, reason: 'Azure credentials' }, + { level: 'high', id: 'docker-config', regex: /(?:^|\/)\.docker\/config\.json$/, reason: 'Docker config may contain registry auth' }, + { level: 'high', id: 'netrc', regex: /(?:^|\/)\.netrc$/, reason: '.netrc contains credentials' }, + { level: 'high', id: 'npmrc', regex: /(?:^|\/)\.npmrc$/, reason: '.npmrc may contain auth tokens' }, + { level: 'high', id: 'pypirc', regex: /(?:^|\/)\.pypirc$/, reason: '.pypirc contains PyPI credentials' }, + { level: 'high', id: 'gem-creds', regex: /(?:^|\/)\.gem\/credentials$/, reason: 'RubyGems credentials' }, + { level: 'high', id: 'vault-token', regex: /(?:^|\/)(\.vault-token|vault-token)$/, reason: 'Vault token file' }, + { level: 'high', id: 'keystore', regex: /\.(keystore|jks)$/i, reason: 'Java keystore' }, + { level: 'high', id: 'htpasswd', regex: /(?:^|\/)\.?htpasswd$/, reason: 'htpasswd contains hashed passwords' }, + { level: 'high', id: 'pgpass', regex: /(?:^|\/)\.pgpass$/, reason: 'PostgreSQL password file' }, + { level: 'high', id: 'my-cnf', regex: /(?:^|\/)\.my\.cnf$/, reason: 'MySQL config may contain password' }, + + // STRICT + { level: 'strict', id: 'database-config', regex: /(?:^|\/)(?:config\/)?database\.(json|ya?ml)$/i, reason: 'Database config may contain passwords' }, + { level: 'strict', id: 'ssh-known-hosts', regex: /(?:^|\/)\.ssh\/known_hosts$/, reason: 'SSH known_hosts reveals infrastructure' }, + { level: 'strict', id: 'gitconfig', regex: /(?:^|\/)\.gitconfig$/, reason: '.gitconfig may contain credentials' }, + { level: 'strict', id: 'curlrc', regex: /(?:^|\/)\.curlrc$/, reason: '.curlrc may contain auth' }, +]; + +// Bash patterns that expose or exfiltrate secrets +const BASH_PATTERNS = [ + // CRITICAL + { level: 'critical', id: 'cat-env', regex: /\b(cat|less|head|tail|more|bat|view)\s+[^|;]*\.env\b/i, reason: 'Reading .env file exposes secrets' }, + { level: 'critical', id: 'cat-ssh-key', regex: /\b(cat|less|head|tail|more|bat)\s+[^|;]*(id_rsa|id_ed25519|id_ecdsa|id_dsa|\.pem|\.key)\b/i, reason: 'Reading private key' }, + { level: 'critical', id: 'cat-aws-creds', regex: /\b(cat|less|head|tail|more)\s+[^|;]*\.aws\/credentials/i, reason: 'Reading AWS credentials' }, + + // HIGH - Environment exposure + { level: 'high', id: 'env-dump', regex: /\bprintenv\b|(?:^|[;&|]\s*)env\s*(?:$|[;&|])/, reason: 'Environment dump may expose secrets' }, + { level: 'high', id: 'echo-secret-var', regex: /\becho\b[^;|&]*\$\{?[A-Za-z_]*(?:SECRET|KEY|TOKEN|PASSWORD|PASSW|CREDENTIAL|API_KEY|AUTH|PRIVATE)[A-Za-z_]*\}?/i, reason: 'Echoing secret variable' }, + { level: 'high', id: 'printf-secret-var', regex: /\bprintf\b[^;|&]*\$\{?[A-Za-z_]*(?:SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL|API_KEY|AUTH|PRIVATE)[A-Za-z_]*\}?/i, reason: 'Printing secret variable' }, + { level: 'high', id: 'cat-secrets-file', regex: /\b(cat|less|head|tail|more)\s+[^|;]*(credentials?|secrets?)\.(json|ya?ml|toml)/i, reason: 'Reading secrets file' }, + { level: 'high', id: 'cat-netrc', regex: /\b(cat|less|head|tail|more)\s+[^|;]*\.netrc/i, reason: 'Reading .netrc credentials' }, + { level: 'high', id: 'source-env', regex: /\bsource\s+[^|;]*\.env\b|(?:^|[;&|]\s*)\.\s+[^|;]*\.env\b|^\.\s+[^|;]*\.env\b/i, reason: 'Sourcing .env loads secrets' }, + { level: 'high', id: 'export-cat-env', regex: /export\s+.*\$\(cat\s+[^)]*\.env/i, reason: 'Exporting secrets from .env' }, + + // HIGH - Exfiltration + { level: 'high', id: 'curl-upload-env', regex: /\bcurl\b[^;|&]*(-d\s*@|-F\s*[^=]+=@|--data[^=]*=@)[^;|&]*(\.env|credentials|secrets|id_rsa|\.pem|\.key)/i, reason: 'Uploading secrets via curl' }, + { level: 'high', id: 'curl-post-secrets', regex: /\bcurl\b[^;|&]*-X\s*POST[^;|&]*[^;|&]*(\.env|credentials|secrets)/i, reason: 'POSTing secrets via curl' }, + { level: 'high', id: 'wget-post-secrets', regex: /\bwget\b[^;|&]*--post-file[^;|&]*(\.env|credentials|secrets)/i, reason: 'POSTing secrets via wget' }, + { level: 'high', id: 'scp-secrets', regex: /\bscp\b[^;|&]*(\.env|credentials|secrets|id_rsa|\.pem|\.key)[^;|&]+:/i, reason: 'Copying secrets via scp' }, + { level: 'high', id: 'rsync-secrets', regex: /\brsync\b[^;|&]*(\.env|credentials|secrets|id_rsa)[^;|&]+:/i, reason: 'Syncing secrets via rsync' }, + { level: 'high', id: 'nc-secrets', regex: /\bnc\b[^;|&]*<[^;|&]*(\.env|credentials|secrets|id_rsa)/i, reason: 'Exfiltrating secrets via netcat' }, + + // HIGH - Copy/move/delete secrets + { level: 'high', id: 'cp-env', regex: /\bcp\b[^;|&]*\.env\b/i, reason: 'Copying .env file' }, + { level: 'high', id: 'cp-ssh-key', regex: /\bcp\b[^;|&]*(id_rsa|id_ed25519|\.pem|\.key)\b/i, reason: 'Copying private key' }, + { level: 'high', id: 'mv-env', regex: /\bmv\b[^;|&]*\.env\b/i, reason: 'Moving .env file' }, + { level: 'high', id: 'rm-ssh-key', regex: /\brm\b[^;|&]*(id_rsa|id_ed25519|id_ecdsa|authorized_keys)/i, reason: 'Deleting SSH key' }, + { level: 'high', id: 'rm-env', regex: /\brm\b.*\.env\b/i, reason: 'Deleting .env file' }, + { level: 'high', id: 'rm-aws-creds', regex: /\brm\b[^;|&]*\.aws\/credentials/i, reason: 'Deleting AWS credentials' }, + { level: 'high', id: 'truncate-secrets', regex: /\btruncate\b.*\.(env|pem|key)\b|(?:^|[;&|]\s*)>\s*\.env\b/i, reason: 'Truncating secrets file' }, + + // HIGH - Process environ + { level: 'high', id: 'proc-environ', regex: /\/proc\/[^/]*\/environ/, reason: 'Reading process environment' }, + { level: 'high', id: 'xargs-cat-env', regex: /xargs.*cat|\.env.*xargs/i, reason: 'Reading .env via xargs' }, + { level: 'high', id: 'find-exec-cat-env', regex: /find\b.*\.env.*-exec|find\b.*-exec.*(cat|less)/i, reason: 'Finding and reading .env files' }, + + // STRICT + { level: 'strict', id: 'grep-password', regex: /\bgrep\b[^|;]*(-r|--recursive)[^|;]*(password|secret|api.?key|token|credential)/i, reason: 'Grep for secrets may expose them' }, + { level: 'strict', id: 'base64-secrets', regex: /\bbase64\b[^|;]*(\.env|credentials|secrets|id_rsa|\.pem)/i, reason: 'Base64 encoding secrets' }, +]; + +const LEVELS = { critical: 1, high: 2, strict: 3 }; +const EMOJIS = { critical: 'πŸ”', high: 'πŸ›‘οΈ', strict: '⚠️' }; +const LOG_DIR = path.join(process.env.HOME, '.claude', 'hooks-logs'); + +function log(data) { + try { + if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + const file = path.join(LOG_DIR, `${new Date().toISOString().slice(0, 10)}.jsonl`); + fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), hook: 'protect-secrets', ...data }) + '\n'); + } catch {} +} + +function isAllowlisted(filePath) { + return filePath && ALLOWLIST.some(p => p.test(filePath)); +} + +function checkFilePath(filePath, safetyLevel = SAFETY_LEVEL) { + if (!filePath || isAllowlisted(filePath)) return { blocked: false, pattern: null }; + const threshold = LEVELS[safetyLevel] || 2; + for (const p of SENSITIVE_FILES) { + if (LEVELS[p.level] <= threshold && p.regex.test(filePath)) { + return { blocked: true, pattern: p }; + } + } + return { blocked: false, pattern: null }; +} + +function checkBashCommand(cmd, safetyLevel = SAFETY_LEVEL) { + if (!cmd) return { blocked: false, pattern: null }; + for (const allow of ALLOWLIST) { + if (allow.test(cmd)) return { blocked: false, pattern: null }; + } + const threshold = LEVELS[safetyLevel] || 2; + for (const p of BASH_PATTERNS) { + if (LEVELS[p.level] <= threshold && p.regex.test(cmd)) { + return { blocked: true, pattern: p }; + } + } + return { blocked: false, pattern: null }; +} + +function check(toolName, toolInput, safetyLevel = SAFETY_LEVEL) { + if (['Read', 'Edit', 'Write'].includes(toolName)) { + return checkFilePath(toolInput?.file_path, safetyLevel); + } + if (toolName === 'Bash') { + return checkBashCommand(toolInput?.command, safetyLevel); + } + return { blocked: false, pattern: null }; +} + +async function main() { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + + try { + const data = JSON.parse(input); + const { tool_name, tool_input, session_id, cwd, permission_mode } = data; + + if (!['Read', 'Edit', 'Write', 'Bash'].includes(tool_name)) { + return console.log('{}'); + } + + const result = check(tool_name, tool_input); + + if (result.blocked) { + const p = result.pattern; + const shouldAsk = ASK[p.level] === true; + const decision = shouldAsk ? 'ask' : 'deny'; + const target = tool_input?.file_path || tool_input?.command?.slice(0, 100); + log({ level: shouldAsk ? 'ASK' : 'BLOCKED', id: p.id, priority: p.level, decision, tool: tool_name, target, session_id, cwd, permission_mode }); + + const action = { Read: 'read', Edit: 'modify', Write: 'write to', Bash: 'execute' }[tool_name]; + return console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: decision, + permissionDecisionReason: `${EMOJIS[p.level]} [${p.id}] Cannot ${action}: ${p.reason}` + } + })); + } + console.log('{}'); + } catch (e) { + log({ level: 'ERROR', error: e.message }); + console.log('{}'); + } +} + +if (require.main === module) { + main(); +} else { + module.exports = { + SENSITIVE_FILES, BASH_PATTERNS, ALLOWLIST, LEVELS, SAFETY_LEVEL, ASK, + check, checkFilePath, checkBashCommand, isAllowlisted, + }; +} diff --git a/plugins/guard-pack/lib/protect-tests.js b/plugins/guard-pack/lib/protect-tests.js new file mode 100644 index 0000000..2325a37 --- /dev/null +++ b/plugins/guard-pack/lib/protect-tests.js @@ -0,0 +1,172 @@ +#!/usr/bin/env node +/** + * Protect Tests - PreToolUse Hook for Bash|Edit|MultiEdit|Write + * Blocks the "fake green" failure mode: an agent that makes a suite pass by + * deleting, renaming-away, or disabling (skip/xfail/ignore) test cases instead + * of fixing the code. Logs to: ~/.claude/hooks-logs/ + * + * SAFETY_LEVEL: 'critical' | 'high' | 'strict' (default: 'high') + * critical - deleting test files or whole test directories (rm / git rm) + * high - + renaming a test file to a disabled name, + introducing a + * skip/xfail/ignore marker into an existing test (Edit/MultiEdit) + * strict - + writing a whole test file that is already skipped (Write) + * Override via the HOOK_SAFETY_LEVEL env var; invalid values fall back to + * 'high'. Tune via env, not by editing this file: plugin updates overwrite it. + * + * It does NOT block writing new, real tests, refactor-renaming a test to another + * test name, or editing test bodies: only removal and disabling. + * + * Setup (plugin, recommended): + * /plugin marketplace add karanb192/claude-code-hooks # once per machine + * /plugin install protect-tests@claude-code-hooks + * + * The classic path still works too: copy this file somewhere stable and + * register it in .claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "Bash|Edit|MultiEdit|Write", + * "hooks": [{ "type": "command", "command": "node /path/to/protect-tests.js" }] + * }] + * } + * } + */ + +const fs = require('fs'); +const path = require('path'); + +// 'critical' | 'high' | 'strict'. Overridable via HOOK_SAFETY_LEVEL so an +// installed plugin can be tuned without editing this file; anything else +// falls back to the default. +const SAFETY_LEVEL = ['critical', 'high', 'strict'].includes(process.env.HOOK_SAFETY_LEVEL) + ? process.env.HOOK_SAFETY_LEVEL + : 'high'; + +const LEVELS = { critical: 1, high: 2, strict: 3 }; +const EMOJIS = { critical: '🚨', high: 'β›”', strict: '⚠️' }; +const LOG_DIR = path.join(process.env.HOME || '/tmp', '.claude', 'hooks-logs'); + +// A path that looks like a test file (many languages / conventions). +const TEST_PATH = new RegExp( + [ + '(^|/)(tests?|__tests__|spec|specs)/', // inside a test directory + '(^|/)test_[^/]+\\.py$', // pytest / unittest + '_test\\.(py|go|rb|js|jsx|ts|tsx|mjs|cjs)$', // *_test.* + '\\.(test|spec)\\.(js|jsx|ts|tsx|mjs|cjs)$', // *.test.* / *.spec.* + '(^|/)[^/]+_spec\\.rb$', // rspec + '(^|/)[^/]*Test\\.(java|kt|cs)$', // JUnit / xUnit + '(^|/)Test[^/]*\\.(java|kt|cs)$', + ].join('|'), + 'i' +); + +// The same, but as it would appear as a token inside a shell command. +const TEST_TOKEN = + /(test_[\w.-]+\.\w+|[\w.-]+_test\.\w+|[\w.-]+\.(test|spec)\.\w+|[\w.-]+_spec\.rb|(^|[\s'"/])(tests?|__tests__|specs?)\/)/; + +const DELETE_VERB = /(\brm\b|\bunlink\b|\bshred\b|\btrash\b|\bgit\s+rm\b)/; +const RENAME_VERB = /\bmv\b/; +const DISABLED_DEST = /(\.bak|\.old|\.orig|\.disabled|\.skip|\.ignore|\.tmp|~)(["'\s]|$)/i; + +// Markers that turn an existing test off in place. +const SKIP_MARKERS = [ + /@pytest\.mark\.(skip|xfail)/, // pytest + /@unittest\.skip/, // unittest + /\bpytest\.skip\s*\(/, + /@Disabled\b/, // JUnit 5 + /@Ignore\b/, // JUnit 4 / TestNG + /\b(it|test|describe|context)\.skip\s*\(/, // jest / mocha / vitest + /\bx(it|describe|test|context)\s*\(/, // xit / xdescribe ... + /\bt\.Skip(Now)?\s*\(/, // Go + /#\[ignore\]/, // Rust + /\[Ignore\]/, // NUnit / MSTest +]; + +function log(data) { + try { + if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + const file = path.join(LOG_DIR, `${new Date().toISOString().slice(0, 10)}.jsonl`); + fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...data }) + '\n'); + } catch {} +} + +function addedMarker(oldStr, newStr) { + const before = oldStr || ''; + for (const m of SKIP_MARKERS) { + if (m.test(newStr) && !m.test(before)) return true; + } + return false; +} + +// Returns { blocked, id, level, reason }: pure, so it is unit-testable. +function checkTool(toolName, toolInput = {}, safetyLevel = SAFETY_LEVEL) { + const threshold = LEVELS[safetyLevel] || 2; + const allow = () => ({ blocked: false }); + const deny = (level, id, reason) => + LEVELS[level] <= threshold ? { blocked: true, id, level, reason } : allow(); + + if (toolName === 'Bash') { + const cmd = toolInput.command || ''; + if (!TEST_TOKEN.test(cmd)) return allow(); + if (DELETE_VERB.test(cmd)) return deny('critical', 'delete-test', 'deleting test file(s) or test directory'); + if (RENAME_VERB.test(cmd) && DISABLED_DEST.test(cmd)) + return deny('high', 'rename-test', 'renaming a test file to a disabled name'); + return allow(); + } + + if (toolName === 'Edit') { + if (TEST_PATH.test(toolInput.file_path || '') && addedMarker(toolInput.old_string, toolInput.new_string)) + return deny('high', 'skip-test', 'adding a skip/xfail/ignore marker to an existing test'); + return allow(); + } + + if (toolName === 'MultiEdit') { + if (TEST_PATH.test(toolInput.file_path || '')) { + for (const e of toolInput.edits || []) { + if (addedMarker(e.old_string, e.new_string)) + return deny('high', 'skip-test', 'adding a skip/xfail/ignore marker to an existing test'); + } + } + return allow(); + } + + if (toolName === 'Write') { + if (TEST_PATH.test(toolInput.file_path || '') && addedMarker('', toolInput.content || '')) + return deny('strict', 'write-skipped-test', 'writing a test file that is already skipped/ignored'); + return allow(); + } + + return allow(); +} + +async function main() { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + + try { + const data = JSON.parse(input); + const { tool_name, tool_input, session_id, cwd, permission_mode } = data; + const result = checkTool(tool_name, tool_input || {}); + + if (result.blocked) { + log({ level: 'BLOCKED', id: result.id, priority: result.level, tool: tool_name, session_id, cwd, permission_mode }); + return console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: `${EMOJIS[result.level]} [${result.id}] ${result.reason}. Fix the code, don't disable the test: or run this manually if the removal is intentional.` + } + })); + } + console.log('{}'); + } catch (e) { + log({ level: 'ERROR', error: e.message }); + console.log('{}'); + } +} + +if (require.main === module) { + main(); +} else { + module.exports = { SKIP_MARKERS, TEST_PATH, LEVELS, SAFETY_LEVEL, checkTool }; +} diff --git a/plugins/guard-pack/tests/guard-pack.test.js b/plugins/guard-pack/tests/guard-pack.test.js new file mode 100644 index 0000000..75f596d --- /dev/null +++ b/plugins/guard-pack/tests/guard-pack.test.js @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * Tests for guard-pack.js + * + * Run: node --test plugins/guard-pack/tests/guard-pack.test.js + * Or: npm test + */ + +const { test, describe, it } = require('node:test'); +const assert = require('node:assert'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); + +const PACK_DIR = path.join(__dirname, '..'); +const SCRIPT_PATH = path.join(PACK_DIR, 'guard-pack.js'); +const { GUARDS } = require(SCRIPT_PATH); + +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-pack-test-')); + +// ───────────────────────────────────────────────────────────────────────────── +// Test helpers +// ───────────────────────────────────────────────────────────────────────────── + +function runHook(payload, envOverrides = {}) { + return new Promise((resolve, reject) => { + const env = { ...process.env, HOME: TMP_HOME, ...envOverrides }; + for (const key of Object.keys(env)) { + if ((key.startsWith('HOOK_ASK_') || key.startsWith('HOOK_SAFETY') || key.startsWith('CONFIG_GUARD_')) && !(key in envOverrides)) { + delete env[key]; + } + } + const child = spawn('node', [SCRIPT_PATH], { env }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + child.on('close', (code) => { + try { resolve({ code, output: JSON.parse(stdout.trim() || '{}'), stderr }); } + catch { reject(new Error(`Failed to parse output: ${stdout}`)); } + }); + child.stdin.write(typeof payload === 'string' ? payload : JSON.stringify(payload)); + child.stdin.end(); + }); +} + +function payload(toolName, toolInput, extra = {}) { + return { tool_name: toolName, tool_input: toolInput, session_id: 'test-session', cwd: TMP_HOME, permission_mode: 'default', ...extra }; +} + +const decisionOf = (output) => output.hookSpecificOutput?.permissionDecision; +const reasonOf = (output) => output.hookSpecificOutput?.permissionDecisionReason || ''; + +// ───────────────────────────────────────────────────────────────────────────── +// Drift pins: the lib copies must stay byte-identical to the plugin sources +// ───────────────────────────────────────────────────────────────────────────── + +describe('Drift: lib copies match the individual guard plugins', () => { + for (const g of GUARDS) { + it(`lib/${g.name}.js is byte-identical to plugins/${g.name}/${g.name}.js`, () => { + const source = fs.readFileSync(path.join(PACK_DIR, '..', g.name, `${g.name}.js`), 'utf8'); + const copy = fs.readFileSync(path.join(PACK_DIR, 'lib', `${g.name}.js`), 'utf8'); + assert.strictEqual(copy, source, `lib/${g.name}.js drifted; copy plugins/${g.name}/${g.name}.js over it`); + }); + } +}); + +describe('Config: GUARDS structure', () => { + it('covers exactly the six guards in cheap-first order', () => { + assert.deepStrictEqual(GUARDS.map((g) => g.name), [ + 'config-guard', 'block-dangerous-commands', 'protect-secrets', + 'protect-tests', 'git-safety', 'case-insensitive-guard', + ]); + }); + it('every guard has an emoji per level and a run function', () => { + for (const g of GUARDS) { + for (const level of ['critical', 'high', 'strict']) assert.ok(g.emojis[level], `${g.name} missing ${level} emoji`); + assert.strictEqual(typeof g.run, 'function'); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Integration: one process, six guards +// ───────────────────────────────────────────────────────────────────────────── + +describe('Integration: verdicts through the pack', () => { + it('benign Bash passes', async () => { + const { code, output } = await runHook(payload('Bash', { command: 'git status && ls src/' })); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + + it('benign Read passes', async () => { + const { output } = await runHook(payload('Read', { file_path: path.join(TMP_HOME, 'notes.md') })); + assert.deepStrictEqual(output, {}); + }); + + it('unmatched tools pass untouched', async () => { + const { output } = await runHook(payload('Grep', { pattern: 'x' })); + assert.deepStrictEqual(output, {}); + }); + + it('block-dangerous-commands: rm -rf ~ denied in its own format', async () => { + const { output } = await runHook(payload('Bash', { command: 'rm -rf ~' })); + assert.strictEqual(decisionOf(output), 'deny'); + assert.match(reasonOf(output), /^🚨 \[rm-home\] rm targeting home directory \(via guard-pack\)$/); + }); + + it('config-guard wins on a settings.json write and names its escape hatch', async () => { + const { output } = await runHook(payload('Write', { file_path: '.claude/settings.json', content: '{}' })); + assert.strictEqual(decisionOf(output), 'deny'); + assert.match(reasonOf(output), /^πŸ”’ \[settings-file\]/); + assert.match(reasonOf(output), /CONFIG_GUARD_ALLOW=true/); + }); + + it('protect-secrets: .env read denied with the Cannot-read phrasing', async () => { + const { output } = await runHook(payload('Read', { file_path: '.env' })); + assert.strictEqual(decisionOf(output), 'deny'); + assert.match(reasonOf(output), /\[env-file\] Cannot read:/); + }); + + it('protect-tests: deleting a test dir denied with its advice line', async () => { + const { output } = await runHook(payload('Bash', { command: 'rm -rf tests/' })); + assert.strictEqual(decisionOf(output), 'deny'); + assert.match(reasonOf(output), /\[delete-test\]/); + assert.match(reasonOf(output), /Fix the code/); + }); + + it('git-safety: push to main denied', async () => { + const { output } = await runHook(payload('Bash', { command: 'git push origin main' })); + assert.strictEqual(decisionOf(output), 'deny'); + assert.match(reasonOf(output), /\[push-main\]/); + }); + + it('every deny carries the (via guard-pack) suffix', async () => { + const { output } = await runHook(payload('Bash', { command: 'rm -rf ~' })); + assert.match(reasonOf(output), /\(via guard-pack\)$/); + }); +}); + +describe('Integration: env passthrough', () => { + it('HOOK_SAFETY_LEVEL=strict tightens every guard in the pack', async () => { + const cmd = 'git push --force origin feature-branch'; + const relaxed = await runHook(payload('Bash', { command: cmd })); + assert.deepStrictEqual(relaxed.output, {}); + const strict = await runHook(payload('Bash', { command: cmd }), { HOOK_SAFETY_LEVEL: 'strict' }); + assert.strictEqual(decisionOf(strict.output), 'deny'); + }); + + it('HOOK_ASK_CRITICAL=true degrades a critical deny to ask', async () => { + const { output } = await runHook(payload('Read', { file_path: '.env' }), { HOOK_ASK_CRITICAL: 'true' }); + assert.strictEqual(decisionOf(output), 'ask'); + }); + + it('ask mode never softens the deny-only guards', async () => { + const { output } = await runHook(payload('Bash', { command: 'git push origin main' }), { HOOK_ASK_HIGH: 'true' }); + assert.strictEqual(decisionOf(output), 'deny'); + }); + + it('CONFIG_GUARD_ALLOW=true skips config-guard but only config-guard', async () => { + const allowed = await runHook(payload('Write', { file_path: '.claude/settings.json', content: '{}' }), { CONFIG_GUARD_ALLOW: 'true' }); + assert.deepStrictEqual(allowed.output, {}); + const stillDenied = await runHook(payload('Read', { file_path: '.env' }), { CONFIG_GUARD_ALLOW: 'true' }); + assert.strictEqual(decisionOf(stillDenied.output), 'deny'); + }); +}); + +describe('Integration: case-insensitive-guard through the pack', () => { + // Only provable on a case-folding volume (macOS APFS default, NTFS). + // On case-sensitive filesystems the collision cannot exist, so assert + // the benign direction there instead of skipping silently. + const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-pack-case-')); + fs.mkdirSync(path.join(probeDir, 'Content')); + const folds = fs.existsSync(path.join(probeDir, 'content')); + + it(folds ? 'denies rm -rf onto a case-variant directory' : 'passes on a case-sensitive volume (collision impossible)', async () => { + const { output } = await runHook(payload('Bash', { command: 'rm -rf content' }, { cwd: probeDir })); + if (folds) { + assert.strictEqual(decisionOf(output), 'deny'); + assert.match(reasonOf(output), /\[case-collision\]/); + } else { + assert.deepStrictEqual(output, {}); + } + }); +}); + +describe('Integration: robustness', () => { + it('malformed JSON exits 0 with {}', async () => { + const { code, output } = await runHook('not json'); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + + it('non-string command passes without crashing', async () => { + const { code, output } = await runHook(payload('Bash', { command: 12345 })); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + + it('missing tool_input passes', async () => { + const { output } = await runHook(payload('Bash', undefined)); + assert.deepStrictEqual(output, {}); + }); +}); + +test('meta: the pack advertises exactly one PreToolUse registration', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(PACK_DIR, 'hooks', 'hooks.json'), 'utf8')); + assert.deepStrictEqual(Object.keys(manifest.hooks), ['PreToolUse']); + assert.strictEqual(manifest.hooks.PreToolUse[0].matcher, 'Bash|Read|Edit|MultiEdit|Write'); +});