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
13 changes: 13 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
]
}
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/)**

Expand All @@ -22,7 +22,7 @@
</tr>
</table>

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 <name>@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/<name>/<name>.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 <name>@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/<name>/<name>.js` and wire it into `settings.json` yourself ([Quick Start](#-quick-start)).

---

Expand Down Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
17 changes: 10 additions & 7 deletions bench/RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions bench/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
21 changes: 21 additions & 0 deletions plugins/guard-pack/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
30 changes: 30 additions & 0 deletions plugins/guard-pack/README.md
Original file line number Diff line number Diff line change
@@ -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.
180 changes: 180 additions & 0 deletions plugins/guard-pack/guard-pack.js
Original file line number Diff line number Diff line change
@@ -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 };
}
15 changes: 15 additions & 0 deletions plugins/guard-pack/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read|Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/guard-pack.js\""
}
]
}
]
}
}
Loading
Loading