Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-1382%20passing-brightgreen)](https://github.com/karanb192/claude-code-hooks/actions/workflows/test.yml)

**🌐 [Live site & catalog](https://karanb192.github.io/claude-code-hooks/)**

Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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):

Expand Down
109 changes: 109 additions & 0 deletions hook-scripts/config-change/config-watch.js
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading