GVNR
The expensive mistakes are free. rm -rf costs nothing. curl … | sh costs nothing. Reading .env costs nothing. A spend cap waves every one of them through on a full budget, because it is answering a different question.
This is the other check: not can it afford this, but may it do this at all.
Two places you can intervene, and they are not equivalent.
| At the API | Before the tool runs | |
|---|---|---|
| Sees the model call | yes | yes |
| Sees a shell command | no | yes |
| Sees a file read | no | yes |
| Can refuse before anything happens | no, the call is already going out | yes |
An API-level guard never sees rm -rf, because deleting a directory is not an API call. If your agent exposes a pre-execution hook, that is where this belongs. Claude Code calls it PreToolUse.
Claude Code pipes a JSON event on stdin before each tool call and reads your JSON on stdout. Three decisions: allow, deny, ask.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "why, in words the human will read"
}
}
The trap: exit 0 and print JSON. Exiting 2 with JSON is ignored, which fails silently in the one direction a guard must never fail in. You get no error, and every action is allowed.
This is self-contained. It needs nothing installed.
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
const RULES = [
{ name: 'pipe the internet into a shell', tool: 'Bash',
match: /(curl|wget)[^|]*\|\s*(ba|z|fi)?sh/i, act: 'deny' },
{ name: 'delete a whole tree', tool: 'Bash',
match: /rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)/i, act: 'ask' },
{ name: 'rewrite git history', tool: 'Bash',
match: /push\s+(--force|-f)\b|reset\s+--hard|filter-branch/i, act: 'ask' },
{ name: 'read or write credentials', tool: '',
match: /\.env\b|id_rsa|\.pem\b|credentials\.json|\.aws\/|\.ssh\//i, act: 'ask' },
{ name: 'publish or deploy', tool: 'Bash',
match: /npm\s+publish|vercel\s+.*--prod|kubectl\s+(apply|delete)|terraform\s+apply/i, act: 'ask' },
];
function emit(decision, reason) {
process.stdout.write(JSON.stringify({ hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: decision,
permissionDecisionReason: reason,
}}));
process.exit(0); // never exit 2 with JSON
}
let ev = {};
try { ev = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch {}
const tool = String(ev.tool_name || '');
const text = `${tool}:${JSON.stringify(ev.tool_input ?? '')}`;
for (const r of RULES) {
if (r.tool && r.tool.toLowerCase() !== tool.toLowerCase()) continue;
if (!r.match.test(text)) continue;
if (r.act === 'deny') emit('deny', `Refused: this would ${r.name}.`);
emit('ask', `This would ${r.name}. Allow it this once?`);
}
emit('allow', 'ok');
Save it, then wire it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "node /abs/path/to/guard.mjs" } ] }
]
}
}
Start a new session for it to load. Test it on something harmless first, for example a command containing .env, and check you get the prompt rather than silence.
The design choice that matters most is having ask at all. Reckless and irreversible are different problems:
Pattern rules are a speed bump, not a sandbox. Published research testing open-source agent guards found most of them defeated by trivial shell obfuscation: r''m -rf, $IFS in place of spaces, base64 round-trips. The guard above is in that class and so is every denylist of this shape.
That is an argument for two things, not for skipping it:
If you only do one thing, take the credentials out of reach. Most of what makes a destructive action catastrophic is what it can authenticate to afterwards.
GVNR ships these rules plus spend and rate limits in one local process, free and self-hosted. We make it, so weigh that accordingly.
npx --yes enforcer-governor install-hook
It adds what the snippet above deliberately leaves out: rate limits in dollars per minute, agents per minute and errors per minute, and a hash-chained record of every decision. The snippet is genuinely enough for the destructive-command problem on its own.