---
title: "The Empty CMD Variable Behind Five Silent PreToolUse Hooks"
canonical: https://dxdev.com/blog/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.

We found it while working through setup for a client. 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
```

Follow that through 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.

## A green test that never touched the failure

We installed `jq` and ran regression tests against the hooks. They passed. So we did the tidy thing: we 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, and it cost us a pushed commit that looked like closure. 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, the same way. The test proved the hooks worked with `jq` present. It said nothing about what they did when `jq` was absent, which was the exact condition behind the incident. We 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 we wrote down: a [guard that cannot read its input must refuse](/everyday-ai/no-answer-should-never-mean-full-access/). A rail that silently opens is worse than no rail, because you stop watching the drop.

Instead of patching four files four ways, we 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. That path is fine, because it really is "nothing to check."

The python backstop is there 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. Which hooks fail closed and which degrade is a per-hook call, and we 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 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.

Check whether each safety check refuses to proceed when a tool it depends on is missing, or passes by default. Then check whether any test covers the missing-dependency case. A green result from a check that never exercised the failure tells you nothing about the failure.
