---
title: "When Your Guards Fail Silent: Bootstrap Dependencies in Safety Checks"
canonical: https://dxdev.com/blog/2026-09-26_bootstrap-dependency-guard-failure/
datePublished: 2026-07-25
---
Five PreToolUse guardrail hooks had been running on every Bash call for weeks, and none of them had blocked anything, because `jq` wasn't installed on the machine.

I found it while working through setup for my partner. The hooks enforce our branch rules: no bare push to the staging branch, no merging staging into develop, no bare ticket-state transitions, and no shell access to a spec directory. They all started the same way:

```bash
INPUT=$(cat)
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)

if [ -z "$CMD" ]; then
  exit 0
fi
```

Look at what that does with no `jq`. The pipe fails, `2>/dev/null` swallows the missing-command error, and `CMD` ends up empty. The `// empty` fallback already makes an empty command a legitimate case, so a missing parser and a real "nothing to check" look identical. The guard takes the early exit and returns 0, which means allow. Every hook looked wired in and none of them was checking anything.

## The green test that proved nothing

I installed `jq` and ran regression tests against the hooks. They passed. So I did the tidy thing. I added `jq` to the prerequisites in the bootstrap doc, with a note explaining that it isn't optional, committed it, and pushed.

That was the wrong fix. The commit encoded the failure as documentation. A fresh machine would get `jq` only if someone read the prereq list. Otherwise it would rebuild the same silent hole, and the hooks would fail open again, quietly, the same way. The test I'd written proved the hooks worked with `jq` present. It said nothing about what they did when `jq` was absent, which was the exact condition that had caused the incident. I had pushed a green result for a check that never exercised the failure.

Reopening the hook files made it plain. Each one still took the same early exit when the command came back empty, and installing a dependency had changed nothing about that.

## Making the rail refuse

The rule I wrote down: a guard that cannot read its input must refuse. A rail that silently opens is worse than no rail, because you stop watching the drop.

Instead of patching four files four ways, I wrote one shared helper, `_hook_json.sh`, and had each guard source it:

```bash
. "$(dirname "${BASH_SOURCE[0]}")/_hook_json.sh"
INPUT=$(cat)
CMD=$(hook_cmd "$INPUT")
```

Inside the helper, parser discovery is a short ladder. It tries `jq` first, then falls back to `python` or `python3`. If neither exists, it emits a deny decision and exits:

```json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "..."
  }
}
```

The reason string says which parser is missing and how to install it, so the refusal tells you what to do. An empty command after a successful parse still returns empty, and that path is fine, because it really is "nothing to check."

The python backstop matters because a guard should depend on as few things as possible, and when it can't run at all, it should say so loudly.

One hook stays out of this. The command rewriter is not a guard, it changes commands rather than approving them. If it failed closed, every Bash call on a machine without a parser would break. For that one, degrading to "no rewrite" is the correct behavior. Deciding which hooks fail closed and which degrade is a per-hook call, and I made it one file at a time.

## Three questions for every guard

Three questions now go on every hook we write:

1. What does it return when its parser is missing?
2. What does it return when its input is empty, versus unparseable?
3. Does any test exercise the missing-dependency path, or only the happy one?

The third question is the one that would have caught this on day one. A test that only runs with the parser present proves the happy path and nothing else.

The uncomfortable part is that nothing alerted. The hooks looked wired in, and the check only failed when someone asked what "ran" was actually worth.
