DEV Community

redpa
redpa

Posted on

Your Claude Code hooks probably fail open — here's why that's dangerous

A few weeks ago I added a Claude Code hook to block destructive shell commands — the usual rm -rf, force-pushes, that kind of thing. Felt good. Slept better.

Then one day I changed something small in the hook and made a typo. The hook threw on startup. And here's the part that got me: Claude Code just... ran the command anyway. No error, no warning, nothing. The guard had quietly stopped guarding, and I only noticed because I happened to read the logs for an unrelated reason.

That's the failure mode I want to talk about, because almost every hook I've seen shared online has it.

What a hook actually is

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 examples look like this:

const input = JSON.parse(fs.readFileSync(0, 'utf8'));
try {
  if (isDangerous(input)) {
    console.log(JSON.stringify({ decision: 'block' }));
  }
} catch (e) {
  // swallow, don't crash
}
Enter fullscreen mode Exit fullscreen mode

Look at that catch. If isDangerous throws — bad input, a regex bug, a typo, anything — the hook prints nothing and exits 0. To Claude Code that reads as "no objection." The action goes through.

So the hook fails open. The one moment you most need the guard — when something unexpected happened — is exactly when it lets go.

The fix is boring: fail closed

For a protection hook, an error should mean block, not allow. If you're not sure whether something is safe, the safe default is no.

I ended up writing a few base classes around this idea and put them on npm as claude-hook-guard. The core rules:

  • A protection hook blocks if your check throws or the hook itself crashes.
  • Every run appends a line to an NDJSON audit log, so you can always answer "did the guard run, and what did it decide?"
  • A hard 5-second timeout, so a hung hook can't wedge your session.
  • The base owns stdout, so you can't accidentally emit two conflicting decisions.
  • A kill-switch and a short-lived bypass token, because a guard you can't turn off in an emergency is its own hazard.

A guard then looks like this — no try/catch, no stdout plumbing, just the rule:

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();
Enter fullscreen mode Exit fullscreen mode

If execute returns, the action is allowed. If it throws — or if the file has a bug and crashes — it's blocked, and a line gets written to the audit log either way.

Why I built it

I'm not a tooling company. I run an English academy and I've automated a big chunk of it on Claude Code — generating exams, QA-ing content, building pages. When you let an agent run real commands against real files every day, "the guard quietly stopped working" is not a hypothetical. The two guards I bundled (dangerous-command-guard and a cloud-sync-git-guard that catches git repos mangled by Dropbox/OneDrive/Synology sync) both came straight out of incidents that actually bit me.

If you're running hooks you'd be sad to see fail silently, it's worth checking whether yours fail open. And if you want the base classes:

Zero dependencies, MIT. Curious whether other people have hit the same thing.

Top comments (0)