claude-hook-guard
v0.1.0
Published
Production-grade Claude Code hooks. Fail closed by default, audit every run, never silently break.
Maintainers
Readme
claude-hook-guard
Production-grade base classes for Claude Code hooks. Fail closed by default, audit every run, never silently break.
A Claude Code hook is a tiny program: it reads a JSON event on stdin, decides what to do, and writes a JSON decision on stdout. Most hooks people share are a bare stdin reader wrapped in a try/catch that swallows errors. That has a nasty failure mode — when the hook itself breaks, it fails open: the action it was supposed to guard sails right through, and nothing tells you the guard stopped working.
claude-hook-guard gives you a small set of base classes that own the lifecycle so that can't happen:
- Fail closed by default — if your check throws, or the hook itself errors, a protection hook blocks. Safety is never an accident of error handling.
- Audited every run — every invocation appends a structured NDJSON record (success, error, or bypass). You can always answer "did the guard run, and what did it decide?"
- Bounded — a hard 5-second timeout means a hung hook can't wedge your session.
- One decision, guaranteed — the base owns stdout, so you can't accidentally emit two conflicting decisions.
- An operator escape hatch — a kill-switch and a time-boxed bypass token, because a guard you can't turn off in an emergency is its own hazard.
Zero dependencies. Node 18+.
Quick start
Get two battle-tested guards running in your project in one command:
npm install claude-hook-guard
npx claude-hook-guard init # adds the bundled hooks to ./.claude/settings.jsoninit is idempotent and backs up any existing settings file. Use --user to
target ~/.claude/settings.json instead. Start a new Claude Code session and
the guards are live.
Or install just the library to build your own hooks:
npm install claude-hook-guardBundled hooks
Two ready-to-use PreToolUse hooks, both built on the base classes below:
dangerous-command-guard— denies destructive commands (rm -rf /,rm -rf ~,git push --force,git reset --hard,mkfs,ddto a disk, fork bombs, …) and writes to secret files (.env,*.pem, SSH keys, AWS credentials). Quote-aware, soecho "rm -rf /"is fine.cloud-sync-git-guard— blocks edits in a git repo that a file-sync service (Dropbox, iCloud, OneDrive, Google Drive, Synology) has likely corrupted: conflicted-copy files, a tree far behind the remote, or a tree missing many files. Catches the case where syncing.gitacross machines leaves you about to push deletions you never made.
Need to run a blocked action once? Prefix it with CHK_BYPASS=1 — the bypass is recorded in the audit log.
Pick the base that matches your hook
| Base | Use it when… | On error |
|---|---|---|
| ProtectionBase | You block a disallowed action (guard a path, refuse a destructive command) | Fail closed — block |
| ValidationBase | You run several checks and want to report all failures at once | Fail closed — block |
| LoggingBase | You observe/record and must not get in the user's way | Fail open on your logic; fail closed on infra errors |
| AuditBase | You record an event that must never be missed | Fail closed — a missing record is the failure |
| HookBase | You need full control | Explicit fail closed (override onError) |
Example: block edits to protected files
#!/usr/bin/env node
const { ProtectionBase, PolicyViolation } = require('claude-hook-guard');
class ProtectPaths extends ProtectionBase {
async execute(input) {
const file = input.tool_input?.file_path || '';
if (/(^|\/)\.env(\.|$)|\.pem$|id_rsa$/.test(file)) {
throw new PolicyViolation(`Refusing to edit a secret file: ${file}`);
}
}
}
new ProtectPaths({ hookName: 'protect-paths' }).run();Wire it up in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "node ~/.claude/hooks/protect-paths.js" }]
}
]
}
}If execute() returns normally, the action is allowed. If it throws PolicyViolation — or if the hook itself crashes — the action is blocked and the reason is shown to Claude. Either way an audit line is written to ~/.claude/hooks/.audit/protect-paths.ndjson.
Example: a Stop hook that checks the job is actually done
#!/usr/bin/env node
const { ValidationBase } = require('claude-hook-guard');
class DoneCheck extends ValidationBase {
async execute(input) {
this.addCheck('tests-ran', () => input.ranTests ? null : 'no test run recorded');
this.addCheck('no-todos', (ctx) => ctx.hasTodos ? 'unresolved TODOs remain' : null);
await this.runChecks({ hasTodos: false });
}
}
new DoneCheck({ hookName: 'done-check' }).run();runChecks runs every check and reports them together, so you see all failures at once instead of one per round-trip.
How a block maps to Claude Code's contract
Getting this wrong is the most common way a hook silently fails open, so the kit handles it for you. When a protection/validation hook blocks, it emits the shape the current Claude Code event expects and exits with the code that actually enforces it:
| Hook event | Emitted on block | Exit |
|---|---|---|
| PreToolUse | hookSpecificOutput.permissionDecision: "deny" (the legacy decision: "approve"/"block" is deprecated for this event) | 0 |
| Stop / SubagentStop | top-level decision: "block" + reason | 0 |
| anything else, or if stdin never parsed | reason on stderr | 2 |
With valid JSON the correct exit code is 0 — the decision rides in the JSON, not the status code. Exit 2 is Claude Code's blocking-error signal (stderr shown). Exit 1 is a non-blocking error: the JSON is ignored and the action proceeds, so the kit never uses it to block. The hook reads hook_event_name from stdin to pick the right format automatically.
Audit records
Every run appends one line to ~/.claude/hooks/.audit/<hookName>.ndjson:
{"ts":"2026-05-24T03:28:00.000Z","hook":"protect-paths","runId":"...","step":0,"jobId":"","workerTokenHash":"","recoveryPath":"fail-closed-block","errorClass":"PolicyViolation","outcome":"error"}Never log raw secrets — hash any worker/identity value into workerTokenHash.
The directory follows CLAUDE_CONFIG_DIR if you've set it, otherwise ~/.claude.
Operator escape hatches
A guard you can't disable in an emergency is a hazard. Three layers, checked before any hook logic runs:
CHK_BYPASS=1env var — bypass for a single command.- A time-boxed bypass token — bypass for N seconds, then it auto-expires:
npx claude-hook-guard bypass issue 120 "reason"(andrevoke/status). - A kill-switch lock file — bypass every hook until you turn it off:
npx claude-hook-guard kill-switch on(andoff/status).
Every bypass is itself written to the audit log, so an escape hatch is never silent.
Design
Each base is a template method: you implement execute(input), and the base handles stdin parsing, the timeout, the single stdout decision, audit logging, and the error policy. Subclasses should not write their own try/catch, call process.exit(), or write to stdout directly — those are exactly the things that let a hook fail open, and the base owns them.
Roadmap
- [x] Base classes (
HookBase,ProtectionBase,ValidationBase,LoggingBase,AuditBase) - [x]
npx claude-hook-guard init— patchsettings.json - [x] Bundled hooks:
dangerous-command-guard,cloud-sync-git-guard - [x] Kill-switch / bypass-token CLI
- [ ]
initscaffolder for a new custom hook from a template - [ ] More bundled hooks (secret-scanner on Write, audit-trail logger)
License
MIT
