Skip to content

fix(guardrails): every hook calling hook::require_jq fails OPEN when jq is absent, while the same guard fails closed on length #2146

Description

@kyle-sexton

Filed by AI. Measured, not read — an earlier draft of this was withheld precisely because a
code-reading is not a verified negative, and readings have been wrong twice on this effort.

Two opposite failure postures in one guard

plugins/guardrails/hooks/block-dangerous-git.sh:

  • Fails CLOSED on length. MAX_COMMAND_LEN=16384 (:105); an over-ceiling command is treated as
    obfuscation and blocked.
  • Fails OPEN on missing jq. hook::require_jq (hook-utils.sh:132) is
    command -v jq >/dev/null 2>&1 && return 0, otherwise a once-per-session skip notice and
    exit 0 — the whole hook is skipped and the tool call proceeds.

Measured, four cells

The two-cell version of this test cannot distinguish "the guard was skipped" from "the harness
returns ALLOW for everything," so the jq-present column is the discrimination control:

                       jq PRESENT   jq HIDDEN
  dangerous push       DENY         ALLOW
  safe command         ALLOW        ALLOW

git push --force origin main is allowed when jq is unreachable. The jq-present column
proves the harness discriminates, so the ALLOW is the guard being skipped and not a stuck function.

Method: jq hidden via a BASH_ENV command() override that intercepts command -v jq and leaves
PATH untouched — stripping PATH directories would also remove git, which the guard needs, and
would produce the same ALLOW for an unrelated reason. The hook is a pure decision function (payloads
in, verdicts out), so nothing was executed and this is read-only by construction.

The length half was verified separately by the same shape: a safe command padded past 16384 is also
DENIED, which proves the denial is length-driven.

Why this is worth a design decision rather than a patch

Both postures are individually defensible and the file argues for each locally. Neither acknowledges
the other, and together they produce an odd threat model:

  • An obfuscation-length command — a deliberate, adversarial act — is treated as hostile and blocked.
  • An environment without jq — an ordinary, undramatic state, and one a user is unlikely to
    associate with a security guard at all — disables the guard entirely, silently after one
    notice per session.

Someone who cannot get a dangerous command past 16384 characters can simply be somewhere without
jq. That is not a sophisticated bypass; it is the default state of a machine that has not installed
one dependency.

hook::require_jq is shared, so this posture is not local to this guard — it is the failure mode of
every hook that calls it, including block-convention-violation, block-hook-bypass, and
block-no-verify. The question is therefore worth answering once, at the helper.

Options, no recommendation implied

  1. Intended, leave it. The plugin's stated philosophy may be that an advisory guard must never
    block work over a missing prerequisite. If so, this issue is closed by writing that reasoning next
    to require_jq, where the two postures can be seen together.
  2. Fail closed for the irreversible-operation guards specifically, keeping fail-open for advisory
    ones. Deny with a message naming jq as the missing prerequisite. Costs a hard dependency on a
    tool the README already lists as required for other plugins.
  3. Degrade rather than skip. A jq-free substring pre-check for the highest-severity forms
    (--force, -f, +refspec, --mirror) — weaker than the real parser, but not nothing. Risks a
    false sense of coverage, which this repository has spent real effort learning to distrust.

Acceptance, whichever is chosen

  • A test pinning the chosen posture with the four-cell shape above. Two cells cannot tell a skipped
    guard from a broken harness.
  • If the posture stays fail-open, the reasoning belongs at hook::require_jq itself, not only at the
    call sites — the call-site comments assert the behaviour, and nothing where the decision is made
    explains why.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions