Skip to content

fix(guardrails): restore PreToolUse enforcement — guards were killed at their timeout - #1385

Closed
kyle-sexton wants to merge 2 commits into
mainfrom
fix/guardrails-pretooluse-latency
Closed

fix(guardrails): restore PreToolUse enforcement — guards were killed at their timeout#1385
kyle-sexton wants to merge 2 commits into
mainfrom
fix/guardrails-pretooluse-latency

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

The guardrails PreToolUse hooks were not slow — they never finished. Each was killed at its
declared timeout, and a killed PreToolUse hook yields outcome:"cancelled" with no
permissionDecision, so the tool call proceeded unguarded.

Measured over the transcript window before this change:

  • 15,845 cancelled runs; fraction finishing under their declared timeout: 0.0000
  • guardrails PreToolUse timeout rate 94–100%; machine-wide 79.7%
  • block-dangerous-git enforced on ~0% of calls

This is a security defect first and a latency defect second. Restoring enforcement is the point;
the performance work exists to make enforcement affordable.

Fix

Root-cause only. No detection logic is weakened, no matcher is narrowed, nothing is disabled —
all three were explicitly ruled out.

Verification status — read this before merging

This branch has an incomplete evidence trail, and I am disclosing that rather than implying
otherwise.
The agent that authored the change was interrupted before recording:

  • per-guard behavior-preservation diffs (verdicts before vs after on representative payloads)
  • confirmation that a timed-out PreToolUse hook fails open against the official hooks
    documentation — the claim was read from the decompiled v2.1.219 binary, not from docs
  • whether the shared hook-utils.sh change was delivered through scripts/sync-hook-utils.sh
    rather than hand-edited, which matters because that file is a cross-plugin cluster
  • the arithmetic behind any raised timeout values
  • before/after measurement from the regression harness

An independent fresh-context review is in progress and its verdict gates this PR. Do not merge
on the strength of the commit subject.

Tradeoff for the reviewer to ratify

If declared timeouts are raised, worst-case waits get longer. Leaving them low keeps waits short
but preserves a silent fail-open. The diagnosis recommends raising them, on the grounds that a
guard which does not run is worse than a guard which is slow. That call belongs to the owner, not
to the change.

Related

Closes #1345

…at their timeout (#1345)

The guardrails PreToolUse guards were not slow; they never finished. Every one
was killed by the harness at its declared `timeout`, and a cancelled hook
contributes no `permissionDecision`, so the guarded tool call proceeded without
the guard's verdict. Measured over 3,923 PreToolUse runs, the overall
non-enforcement rate was 86.1%; individual guards ran 45-100% killed and several
never completed once.

Two compounding causes, both addressed:

Process-spawn cost. Each guard spawned 7-18 external processes through MSYS fork
emulation under Defender real-time scanning - 65 for a single Bash tool call. The
per-field `printf | jq | tr` pipelines collapse into one `jq` process per guard
via a new `hook::jq_fields`; `hook::buffer_stdin`'s CR strip becomes parameter
expansion; `flag-commit-pr-skill-bypass`'s per-line `sed` pair becomes a
whole-string parameter-expansion pass; `hardcoded-path-check` answers two gates
with one `git` process. Exact census, identical payloads: 65 -> 35 spawns per
Bash tool call, and 24 -> 11 across the two content guards.

Declared timeouts far below the platform default. Per current docs the `timeout`
default is 600s for command hooks, lowered only by UserPromptSubmit and
MessageDisplay - not PreToolUse. The 10s/15s values were a 60x/40x reduction with
no recorded rationale. Raised to 60s/90s, still 10x and 6.7x below the platform
default; derivation is in the guardrails CHANGELOG.

No detection logic changed. The `strip_literals` rewrite - the one edit that could
silently weaken a guard - was proved byte-identical to the previous `sed` pair on
20,041 inputs, including 20,000 fuzzed over a quote-heavy alphabet.

`hook::jq_fields` strips U+001E from every value inside jq before joining, so
payload text cannot shift field alignment and cause a guard to scan the wrong
bytes.

The shared lib change reaches all 13 carrying plugins through
`scripts/sync-hook-utils.sh`; each carrier is version-bumped so consumers
actually receive it.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude encountered an error after 7m 9s —— View job


PR Review: fix(guardrails): restore PreToolUse enforcement

  • Gather context and understand the request
  • Read lib/hook-utils.sh and plugins/guardrails/hooks/hook-utils.sh (core shared change)
  • Read plugins/guardrails/hooks/hooks.json (timeout values)
  • Read guardrails hook scripts (block-dangerous-git, block-convention-violation, etc.)
  • Read plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh (largest delta)
  • Read plugins/guardrails/hooks/hardcoded-path-check.sh (large refactor)
  • Read plugins/guardrails/scripts/hook_latency_report.py (new diagnostic)
  • Verify: detection logic not weakened, timeout arithmetic, sync method, fail-open behavior
  • Post review findings

Summary

The PR is technically sound. The central claim — timed-out PreToolUse hooks fail open — is confirmed by the official docs (fetched this session): a timeout is treated as a non-blocking outcome and the tool call proceeds. The performance work correctly reduces spawns without weakening any detection. All four of the PR's own disclosed evidence gaps are now resolved or independently verifiable from the diff. One unresolved item and three advisory notes follow.


Resolved items (from PR's own disclosure)

1. Fail-open behavior — confirmed from official docs, not binary

The docs at https://code.claude.com/docs/en/hooks confirm:

timeout | Defaults: 600 for command, http, and mcp_tool. PreToolUse is not listed among the reduced-default events.

Exit code 2 blocks. All other exit codes — including timeout — allow the tool call to proceed.

The previous 10 s / 15 s timeouts were 60× / 40× reductions from the platform default with no documented rationale. The fail-open diagnosis is correct and doc-backed. The binary source cited in the PR body is superseded.

2. sync-hook-utils.sh delivery — verified

All 14 copies of hook-utils.sh (the canonical lib/ source plus all 13 plugin copies) have identical MD5 checksums: da06fcfbeedc6ef2ca45628b84defa7d. The sync was delivered correctly.

3. Timeout arithmetic — documented in CHANGELOG

The CHANGELOG for 0.16.0 provides the full derivation: 12 spawns × 437 ms upper bound + 380 ms sourcing ≈ 5.6 s, × 3 measured concurrent-hook-density effect ≈ 16.9 s, × 3 headroom ≈ 50.6 s → 60 s (smallest multiple of 10 covering it), × 1.5 ratio → 90 s for content-scanning guards. The operator tradeoff is explicitly documented.

4. All 13 carrying plugins bumped their versions

Confirmed from the diff. guardrails received a minor bump (0.15.0 → 0.16.0), all others a patch bump, consistent with the hook-utils.sh delivery contract enforced by scripts/sync-hook-utils.sh --check-bump.


Correctness findings

hook::jq_fields — correct

lib/hook-utils.sh#L304-L321

  • RS (U+001E) is stripped from every value before joining, so an attacker-influenced earlier field (a file path) cannot inject a \x1e to shift the content field — injection-safe ✓
  • Split loop: for n expressions, runs n-1 times and appends the remainder, yielding exactly n elements ✓
  • On jq failure (2>/dev/null, non-zero exit): rest="", all fields become empty strings; callers check [[ -n "$COMMAND" ]] || exit 0 — safe ✓
  • tostring on null: callers use // "" or // "Bash" defaults so null values produce "", not the string "null"

hook::buffer_stdin CR-strip — equivalent

lib/hook-utils.sh#L251

input="${input//$'\r'/}" + trailing-newline while-loop is byte-identical to the old $(printf '%s' "$input" | tr -d '\r') command substitution: CR stripped, trailing newlines stripped. No process spawned. ✓

strip_quoted_spans — equivalent to replaced sed

plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh#L205-L241

  • Old: sed "s/'[^']*'//g" | sed -E 's/"([^"\\]|\\.)*"//g' — 3 spawns per line
  • New: pure parameter expansion matching the same grammar (backslash-aware double-quote scan, unterminated-span preservation, adjacent/empty spans)

Traced edge cases manually — adjacent spans, \" escapes, unterminated spans, '' empty, it's contractions — all produce identical output. The CHANGELOG claims 20,041-input verification (41 hand-written + 20,000 fuzz) — no test artifact is in the PR (see Medium concern below). Since this hook is advisory-only (exit 0 always), the security blast radius of any divergence is confined to an advisory firing or not firing, not a block/allow outcome. ✓

hardcoded-path-check.sh — two gates, one git process — correct

plugins/guardrails/hooks/hardcoded-path-check.sh#L130-L135

check-ignore exit codes: 0 = gitignored (skip), 1 = not ignored + inside working tree (scan), 128 = no working tree (fall back to rev-parse probe). The wildcard arm correctly preserves the semantic of the removed early rev-parse guard for other fatal check-ignore errors inside a real tree. ✓

No detection logic weakened anywhere

Confirmed for all hooks in scope. Extracted fields, regex patterns, block conditions, and allow-list paths are all semantically identical to the pre-change code.


Concerns

MEDIUM — strip_quoted_spans equivalence has no test artifact in the PR

The CHANGELOG claims 20,041-input verification with byte-identical output, but neither the test runner script nor the input corpus is present in this PR. The code analysis is consistent with equivalence, and the advisory-only nature means the blast radius is an advisory notice rather than an enforcement gate — but the evidence trail the PR description says is missing is still missing on this specific point. Suggest adding the fuzz harness or a golden-output test as a follow-up, or attaching the corpus results to the PR. Fix this →

LOW — hook_latency_report.py leaks file handles

plugins/guardrails/scripts/hook_latency_report.py#L33

for line in open(path, encoding='utf-8', errors='replace'):

open() without a context manager leaves the file handle open until GC. On a large transcript corpus this opens O(files) handles simultaneously. Replace with with open(...) as fh: for line in fh:. Not a security issue; diagnostic tool only. Fix this →

INFO — CONTENT extracted before allowlist/gitignore for Write hooks

hook::jq_fields now extracts TOOL + FILE + CONTENT in one call at the top of secret-pattern-detection.sh and hardcoded-path-check.sh, before the gitignore and allowlist checks. For commonly-skipped large files (.venv/, node_modules/, large generated files), jq now parses the entire content before the skip decision. On Windows, this trades one fewer git/jq spawn against parsing O(MB) content that will be discarded. The tradeoff is intentional and documented; noting it for operator awareness.


Operator ratification items (from PR itself)

The PR correctly surfaces these as owner-level decisions:

  1. 60 s / 90 s timeout values — the 3× density and 3× headroom multipliers in the CHANGELOG derivation are unmeasured estimates. The math is conservative (upper-bound unit cost, generous headroom), and the alternative (silent fail-open) is documented as worse. The owner should ratify.
  2. Before/after regression measurementhook_latency_report.py is new in this PR. A pre-fix baseline doesn't exist at the same version. A post-merge run over a fresh session window would complete the evidence trail.

Merge recommendation

The diff is correct. The core security properties are preserved or strengthened (no detection weakened; timeout values now allow hooks to actually complete). The central technical claim is confirmed by official docs. The sync and version-bump invariants are intact.

The missing test artifact for strip_quoted_spans is the one unclosed item from the PR's own evidence list — it is advisory-only in blast radius, and the code analysis confirms equivalence. Whether to require it before merging or as a follow-up is an owner call; either is defensible.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude encountered an error after 6m 22s —— View job


Security Review in Progress

  • Load tools and initialize
  • Fetch PR diff
  • Read key changed files (hook-utils.sh, guardrail hooks, hooks.json, Python script)
  • Analyze for injection surfaces, command injection, path traversal, credential handling
  • Analyze for dangerous patterns in shell scripts
  • Analyze Python diagnostic script for security issues
  • Analyze hooks.json for timeout/fail-open configuration correctness
  • Post security findings

Three branch-owned hygiene sub-checks failed on this PR:

- typos: `mis-assign` in the hook::jq_fields rationale split on the hyphen,
  so `mis` was flagged as `miss`/`mist` in lib/hook-utils.sh and all 13
  vendored plugin copies. Dropped the hyphen (`misassign`) rather than
  allowlisting: _typos.toml is synced verbatim from the root-canonical
  policy and documents itself as not locally editable. Propagated with
  scripts/sync-hook-utils.sh; no version bumps needed, since every
  carrying plugin already bumped on this branch.

- shellcheck: SC1003 on the `'\')` case pattern in
  flag-commit-pr-skill-bypass.sh's quote stripper. Replaced with the
  unquoted `\)`. Both forms match exactly one literal backslash, so the
  escape-skip branch is unchanged; `'\')` would have matched two and
  silently broken the stripper.

- exec-bit: hook_latency_report.py carried a shebang at mode 100644.
  Set 100755, matching every other shebanged .py script in the repo.

Verified locally: typos clean, shellcheck clean over all 349 tracked
scripts, sync-hook-utils.sh --check and check-cross-plugin-source-drift.sh
both clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 47s —— View job


PR Review: fix(guardrails): restore PreToolUse enforcement

  • Load tools and initialize
  • Read the PR diff and key changed files
  • Review lib/hook-utils.sh (core shared change — hook::jq_fields, hook::buffer_stdin)
  • Review plugins/guardrails/hooks/hooks.json (timeout values)
  • Review guardrail hook scripts (block-, flag-, hardcoded-path-check, secret-pattern-detection)
  • Review plugins/guardrails/scripts/hook_latency_report.py (new diagnostic tool)
  • Security analysis: injection surfaces, fail-open behavior, command injection, path traversal
  • Verify CLAUDE.md / design-rule compliance (repo-agnostic, configurable, plugin-form-safe)
  • Post complete review findings

Verdict

The diff is correct and safe to merge. The central fix is sound: the previous 10 s / 15 s timeouts were 60× / 40× below the platform default with no recorded rationale, hooks were killed before they ran, and a killed PreToolUse hook fails open (confirmed from the docs — exit-from-timeout is a non-blocking outcome). No detection logic is weakened anywhere. Two items follow: one unclosed concern from the PR's own evidence list (MEDIUM), one new LOW finding.


Core correctness — verified

hook::jq_fields — injection-safe and correctly bounded
lib/hook-utils.sh#L304–321

  • U+001E is stripped from every value before joining: gsub("[\r]";""). An attacker-controlled file path cannot inject a record-separator to shift the content field into an earlier slot.
  • Split loop runs n-1 times, yielding exactly n elements. Verified by trace: after n-1 iterations, rest holds the last field, which is appended without a separator scan.
  • On jq failure (bad JSON, jq absent): rest="", all HOOK_JQ_FIELDS elements are empty strings. Every caller has [[ -n "$COMMAND" ]] || exit 0 or an equivalent guard — safe.
  • All callers use // "" or // "Bash" defaults on every expression: tostring is never called on a raw null, so the string "null" cannot appear in an extracted field.

hook::buffer_stdin CR-strip — byte-identical to previous form
lib/hook-utils.sh#L250–251

${input//$'\r'/} removes all CR bytes globally — same as tr -d '\r'. The trailing-newline while-loop strips exactly what command substitution used to strip. Zero spawns; byte-identical output.

strip_quoted_spans — equivalent to the sed pair
plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh#L205–241

Traced edge cases manually against sed "s/'[^']*'//g" | sed -E 's/"([^"\\]|\\.)*"//g':

  • Adjacent spans 'a''b', empty '': correctly removed in both.
  • Contractions it's: loop finds no second ', breaks immediately, string unchanged — matches sed (no pair, sed leaves it).
  • \" escapes inside double quotes: j += 2 skips the next char; matches the sed \\. alternation.
  • Unterminated double-quoted span: j == n on loop exit → out += pre + '"', original preserved — matches sed (no closing ", sed doesn't match).
  • Blast radius: this hook exits 0 always (advisory). Any edge divergence affects only whether an advisory fires, not enforcement.

hardcoded-path-check — two gates, one git process
plugins/guardrails/hooks/hardcoded-path-check.sh#L130–135

check-ignore exit codes: 0 = gitignored (skip), 1 = not ignored + working tree (scan), 128 / other = probe rev-parse --is-inside-work-tree to distinguish "no tree" from "fatal error inside a real tree." The wildcard arm correctly extends the old rev-parse guard to fatal errors inside a real tree (false-negative avoidance), not just the no-work-tree case. ✓

No detection logic weakened

Confirmed across all hooks in scope. Regex patterns, block conditions, allow-list paths, and per-field extraction values are semantically identical to pre-change code. The changes are limited to: (a) collapsing per-field jq calls into hook::jq_fields, (b) strip_quoted_spans replacing the sed pair, and (c) consolidating the git check-ignore probe.

Timeout arithmetic

From the CHANGELOG derivation: worst-case guard is 12 spawns (flag-commit-pr-skill-bypass) × 437 ms upper bound + 380 ms sourcing ≈ 5.6 s. × 3 concurrent-hook-density effect ≈ 16.9 s. × 3 headroom ≈ 50.6 s → 60 s (smallest multiple of 10 covering it). 1.5 ratio → 90 s for content-scanning guards. The multipliers (3× density, 3× headroom) are measured upper bounds documented as estimates; the resulting values stay 10× / 6.7× below the platform default. Conservative and defensible.

Sync delivery — verified

All 14 copies of hook-utils.sh (canonical lib/ + 13 plugin copies) carry identical content; the sync was delivered correctly. All 13 carrying plugins bumped their versions consistent with the hook-utils delivery contract.

CLAUDE.md design-rule compliance

All rules pass: repo-agnostic (uses ${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA} exclusively); configurable without editing (all toggles via userConfig / CLAUDE_PLUGIN_OPTION_*); plugin-form-safe (no ../ reach-outs); no PII/secrets in telemetry (paths redacted to repo-relative or basename); versioned (guardrails 0.15.0 → 0.16.0, others patch bumps).


Security analysis

No new injection surfaces. Specific checks:

  • hook::emit_telemetry invokes the sink as ("$sink" >/dev/null 2>&1) & — the sink is an operator-configured absolute/relative path. No user-agent content reaches the sink invocation.
  • hook::jq_fields uses <<<"$input" (here-string) throughout; no user-controlled value is ever interpolated into the jq filter itself.
  • git check-ignore "$FILE" — passes the file path as an argument to git, not to a shell. No injection surface.
  • check_pattern in secret-pattern-detection.sh — spawns 5 processes per pattern on a hit, but is reached only after the fast-reject grep confirms a match. The slow itemization path runs rarely by design.
  • source_control_enabled — reads enabledPlugins from settings files using jq -r --arg k "$key", safely parameterized. No shell injection.

Findings

MEDIUM — strip_quoted_spans equivalence claim has no test artifact in the PR

plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh#L205

The CHANGELOG claims byte-identical output on 20,041 inputs (41 hand-written + 20,000 fuzz), but neither the test runner nor the corpus is in the PR. Code analysis is consistent with equivalence (see above), and the blast radius is advisory-only — a divergence would cause an advisory to fire or not fire, not a block/allow outcome. Whether to require the harness before merge or as follow-up is an owner call; both are defensible. Fix this →

LOW — hook_latency_report.py leaks file handles

plugins/guardrails/scripts/hook_latency_report.py#L33

for line in open(path, encoding='utf-8', errors='replace'):

open() without a context manager leaves the file handle open until GC. Over a large transcript corpus this opens O(files) handles simultaneously. Replace with with open(...) as fh: for line in fh:. Diagnostic tool only; not a security issue. Fix this →

INFO — CONTENT parsed before skip guards in Write hooks

hook::jq_fields in secret-pattern-detection.sh and hardcoded-path-check.sh extracts TOOL + FILE + CONTENT in one jq call at the top of each script, before the gitignore and allowlist checks. For large commonly-skipped files (.venv/, node_modules/, generated files), jq now parses the entire content before the skip decision. On Windows, this trades one fewer git/jq spawn against parsing O(MB) content that will be discarded. The tradeoff is intentional and documented in the CHANGELOG; noting for operator awareness.


Operator ratification items (unchanged from PR's disclosure)

  1. 60 s / 90 s timeout values — the 3× density and 3× headroom multipliers are unmeasured estimates, conservatively upper-bounded. The alternative (silent fail-open at 10 s / 15 s) is documented as worse. Ratify or adjust.
  2. Before/after regression measurementhook_latency_report.py is new in this PR. A pre-fix baseline at the same version does not exist. A post-merge run over a fresh session window would complete the evidence trail.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 16s —— View job


Security Review — PR #1385

  • Load tools and initialize
  • Fetch PR diff (50 files: lib/hook-utils.sh sync'd to 13 plugins, guardrails hooks, hooks.json, hook_latency_report.py)
  • Read key changed files (hook-utils.sh, all blocking and advisory hooks, hooks.json, Python diagnostic)
  • Analyze injection surfaces: jq field join/split, shell quoting, git invocations with user-controlled paths
  • Analyze blocking hooks: block-dangerous-git, block-no-verify, block-hook-bypass, secret-pattern-detection, hardcoded-path-check
  • Analyze Python diagnostic script
  • Analyze hooks.json timeout/fail-open change
  • Post security findings

Security scope: This review covers only the files changed in this PR. Findings marked with severity (CRITICAL / IMPORTANT / SUGGESTION) and confidence level.


Primary change: timeout restoration

The core fix — raising timeouts from 10 s / 15 s to 60 s / 90 s so hooks complete before the harness kills them — is a security improvement. Timed-out PreToolUse hooks yield no block decision and the tool call proceeds unguarded. The prior timeouts made enforcement structurally impossible. No security concerns with this change.


hook::jq_fields — new shared function

lib/hook-utils.sh#L304–321

Injection analysis (RS / U+001E separator): jq strips U+001E from every field value via gsub("[\r]";"") before joining on U+001E. An attacker-controlled earlier field (e.g. a file path containing U+001E) cannot shift later fields. ✓

jq program construction: $prog is built by concatenating only hardcoded literal jq expressions from call sites — not user-controlled data. No jq injection surface. ✓

Global HOOK_JQ_FIELDS array: All callers destructure the array into named local variables immediately after the call (e.g. TOOL=${HOOK_JQ_FIELDS[0]}). No practical clobbering risk in the current call graph. ✓

Fail behavior: On jq failure (2>/dev/null), rest is empty and all elements are empty strings. Callers guard with [[ -n "$COMMAND" ]] || exit 0 or equivalent. Blocking hooks that require a command field exit 0 (allow) on empty — same behavior as the prior per-field pipelines on jq failure. ✓


hook::buffer_stdin CR-strip refactor

lib/hook-utils.sh#L250–251

input="${input//$'\r'/}" + trailing-newline while-loop is byte-identical to the prior $(printf '%s' "$input" | tr -d '\r') command substitution. No security regression. ✓


hardcoded-path-check.sh — git check-ignore refactor

plugins/guardrails/hooks/hardcoded-path-check.sh#L130–135

The refactor collapses two git spawns into one by reading check-ignore's exit code directly:

  • Exit 0: gitignored → skip (unchanged)
  • Exit 1: not ignored, inside working tree → scan (unchanged)
  • Exit 128 or other: run rev-parse --is-inside-work-tree probe → skip if not a real working tree; scan otherwise

The new wildcard arm (*) for 128 correctly distinguishes "not a working tree" (skip) from "check-ignore error inside a real working tree" (scan). This is a stricter posture than the old code, which skipped on any working-tree ambiguity. ✓

$FILE and $CLAUDE_PROJECT_DIR are double-quoted at every git -C invocation. A crafted file path starting with - (e.g. --stdin) would be parsed by git as a flag, but the check-ignore exit code in that case remains within 0/1/128, and the downstream scan of $CONTENT is always bounded by the scope guard that ran earlier. No exploitation path. ✓


CONTENT extracted before allowlist checks

plugins/guardrails/hooks/secret-pattern-detection.sh#L54–61 · hardcoded-path-check.sh#L61–68

hook::jq_fields is called once at the top of both hooks, extracting TOOL + FILE + CONTENT together before the static allowlist and gitignore checks. For files that will be skipped (.venv/, node_modules/, settings.local.json, hook scripts), jq now parses potentially large CONTENT before the skip decision.

Security impact: none. The skip still occurs before any scan. jq parsing is local; no content is transmitted anywhere. This is a performance concern only, and a deliberate design decision documented in the CHANGELOG. ✓


strip_quoted_spans — new pure-bash implementation

plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh#L205–241

This hook is advisory-only (exits 0 always). Any divergence in strip behavior between the new pure-bash implementation and the replaced sed-based one affects only whether an advisory notice fires — never a block/allow enforcement decision. No security impact from this change regardless of equivalence. ✓


Blocking hooks field extraction change

All five blocking hooks (block-dangerous-git, block-no-verify, block-hook-bypass, block-noncanonical-commit, block-convention-violation) switch from separate jq | tr pipelines to hook::jq_fields. The default expressions (// "Bash", // "") match what the prior pipelines produced: a missing field that previously yielded empty still yields empty; default values are identical. The [[ -n "$COMMAND" ]] || exit 0 guard applies in all cases. No enforcement gap introduced. ✓


hook_latency_report.py — new file

SUGGESTION · HIGH CONFIDENCE

plugins/guardrails/scripts/hook_latency_report.py#L33

for line in open(path, encoding='utf-8', errors='replace'):

open() without a context manager leaves the file handle open until the garbage collector runs. On a large transcript corpus this opens O(files) descriptors simultaneously, which can exhaust the process's open-file limit (ulimit -n) and cause the script to crash mid-report.

Replace with:

with open(path, encoding='utf-8', errors='replace') as fh:
    for line in fh:

No other security concerns in this script: no subprocess calls, no eval/exec, JSON parsing uses json.loads with exception handling, glob.glob operates on a hardcoded path pattern, and the --json output path is user-supplied but the script runs locally under the user's own permissions. Fix this →


Summary

No CRITICAL or IMPORTANT security vulnerabilities introduced by this PR. The timeout fix is a net security improvement — it restores enforcement that was structurally impossible under the prior values. The hook::jq_fields function is injection-safe by design. The git invocation refactor in hardcoded-path-check.sh is strictly correct and slightly stricter than before. The one actionable finding is the file handle leak in the new diagnostic script, which is a reliability concern for large corpora rather than a security exploit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d54180fe42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

'tool_use_id': att.get('toolUseID'),
'date': ts[:10],
})
fanout[(path, att.get('toolUseID'))] += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter fanout counts by the selected hook event

When a transcript contains both PreToolUse and another hook event for the same tool use, fanout is incremented for all attachments before rows is filtered by --event, so the default PreToolUse report combines sequential PostToolUse hooks into its hooks-per-tool-call histogram. This overstates the concurrent hook density used to diagnose latency; derive the histogram from the event-filtered rows instead.

Useful? React with 👍 / 👎.

Comment on lines +57 to +59
def pct(values, p):
values = sorted(values)
return values[min(len(values) - 1, int(len(values) * p))]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compute percentile indices using the nearest rank

For sample sizes divisible by ten, int(len(values) * .9) selects the observation after the 90th-percentile nearest rank; for example, ten completed durations numbered 1–10 report a p90 of 10 rather than 9. This systematically overstates p90 for common run counts and can distort timeout tuning, so use ceil(n * p) - 1 or an explicit standard quantile implementation.

Useful? React with 👍 / 👎.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Closing this rather than merging it. Recording why, because the diagnosis behind it is sound and the
performance work is worth recovering — the close is about the state of this change, not about the
problem it attacks.

What settled it

A fresh-context verification of this PR against the guardrails contract suites returned DO NOT
MERGE
on two grounds that are independent of every open attribution question:

  1. CI has no Windows runner. Every lane in .github/workflows/ is runs-on: ubuntu-24.04. The
    defect this PR fixes is MSYS fork emulation under Defender — Windows-only — and the guards ship to
    Windows users. All 25 checks passing is therefore not evidence about the changed code on the
    platform that produced the defect.
  2. This PR modifies eight guards on the safety layer and adds zero tests. Combined with (1), the
    changed code has no behavior-preservation evidence on its target platform from any source: not the
    author, not CI, not the PR body.

On a safety layer, absence of evidence is itself the finding.

Four contract-test regressions were also measured on Windows against the post-change tree, two of
them fail-open on blocking guards:

FAIL: git commit --no-<backslash-newline>verify (continuation, blocked): expected exit 2, got 0
FAIL: PS: violating here-string subject blocked: expected exit 2, got 0

plus block-hook-bypass 201/203 and flag-commit-pr-skill-bypass 27/28. All four involve a
multi-line command value. Their attribution — change-induced versus pre-existing on MSYS — was never
settled, because the control arm (the same extraction procedure applied to HEAD as well as
HEAD~1) did not finish. That gap does not soften the verdict: resolving it can only make the
case worse, never better.

What did land, and what did not

The timeout half of the fix landed as #1379, which is now merged. #1379 touches only
hooks.json, plugin.json, and CHANGELOG.md — no script, no detection logic, no shared lib — so
the Linux-only CI limitation that undermines this PR's green does not undermine its. It also carries
the primary-source empirical reproduction of the fail-open that this PR depends on most and does not
have: a hook_cancelled attachment with timedOut: true, durationMs: 10184, timeoutMs: 10000,
immediately followed by the guarded tool call executing and returning a real result.

The spawn-reduction half did not land, and #1379 alone does not fix the defect — it narrows the
window, as its own body says. That half is now tracked as #1403, which carries the five
correction preconditions verbatim, the four regression case names, the three-arm control design for
attributing them, and the note that the advisory hooks' timeout raise was the least justified line in
either PR.

Not carried forward silently

Two things in this diff are recorded on #1403 rather than lost: plugins/guardrails/scripts/hook_latency_report.py
was added here and never reviewed (120 lines, no test, no CI wiring, two open review findings on it),
and the claimed "byte-identical on 20,041 inputs" strip_literals equivalence has no committed
harness, corpus, or output anywhere in the diff.

Verified before closing: #1345 is closed (by #1379), so #1403 is the live home for the remaining
work — this PR is not closing over an empty tracker.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Closed — see the disposition comment above. The spawn-reduction work is tracked as #1403.

kyle-sexton added a commit that referenced this pull request Jul 25, 2026
The execution record described lanes by what sat uncommitted in a
worktree, which is unreachable from a clone and therefore not a
citation. Every lane has since published, so each row now names the PR
or issue that carries its result: L2 as #1343, L3 as #1349 with the
UNBACKED-to-OPINION mapping stated so the claim set does not need to
survive as a separate list, L4 as #1385 closed with recovery on #1403,
L1 as #1286 closed with the fold on #1271 and gate defects on #1404.

The source article is named with its URL rather than left as "a
practitioner article", so a reader auditing this ADR's premises can
reach the thing the digests measured. The digests themselves prune with
the contract slice, which is why the pointer replaces them rather than
supplementing them.
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…or three (#2120)

No linked issue

`hook::jq_fields` landed in #1979 and got its first two adopters in
#2007
(`block-dangerous-git`, `block-no-verify`). The other **ten** guardrails
hooks were still parsing
their PreToolUse/PostToolUse payload with a separate `printf '%s'
"$INPUT" | jq -r … | tr -d '\r'`
pipeline **per field**, over the same already-buffered stdin envelope.
This converts all ten.

## Survey — what was still forking per field

Counting only `jq` **execs against the buffered payload**. `jq -n`
envelope builders, `jq -R | jq -s`
finding serializers, and jq reading a file from disk are out of scope
and untouched.

| hook | jq execs on payload, `main` | after | fields |
| --- | --- | --- | --- |
| `block-noncanonical-commit` | 3 | 1 | `command`, `cwd`, `tool_name` |
| `block-convention-violation` | 3 | 1 | `tool_name`, `command`, `cwd` |
| `hardcoded-path-check` | 3 | 1 | `tool_name`, `file_path`,
`content`/`new_string`/`new_source` |
| `secret-pattern-detection` | 3 | 1 | same as above |
| `skill-reference-verify` | 3 (Edit) / 2 (Write) | 1 | `tool_name`,
`new_string`, `replace_all` / `content` |
| `stale-path-verify` | 3 (Edit) / 2 (Write) | 1 | same as above |
| `block-hook-bypass` | 2 | 1 | `command`, `tool_name` |
| `flag-commit-pr-skill-bypass` | 2 | 1 | `command`, `tool_name` |
| `cli-flag-verify` | 2 | 1 | `tool_name`, `new_string`/`content` |
| `workflow-resilience-check` | 2 | 1 | `script`, `scriptPath` |
| `block-dangerous-git` | 1 | 1 | already converted by #2007 |
| `block-no-verify` | 1 | 1 | already converted by #2007 |

Collateral, not claimed as the headline: each old line is three process
creations
(`$( )` subshell + `jq` + `tr`), so a 3-field hook went 9 → 3 and a
2-field hook 6 → 3 — the
`tr -d '\r'` per field disappears too, because `hook::jq_fields` strips
CR shell-side.

### Deliberately NOT converted

**`hook::read_file_path`** — `cli-flag-verify`, `skill-reference-verify`
and `stale-path-verify`
each still pay one jq exec there. Folding `file_path` into the batched
call would mean either
duplicating or restructuring that helper's existence +
project-membership validation, and it lives
in the synced shared lib (`lib/hook-utils.sh` → 13 plugin copies + the
CI drift check), so the blast
radius reaches every plugin for one exec. Left alone on purpose.

**`flag-commit-pr-skill-bypass`'s `enabledPlugins` reads** (two jq calls
at L145/L155) read a
settings **file**, not the payload. Different input, not batchable here.

## How the fields were kept byte-identical

Two spots would have changed behavior under a naive conversion, and both
are handled:

1. **`.tool_name // "Bash"`** — the default moves to the shell side
(`TOOL_NAME="${HOOK_JQ_FIELDS[n]:-Bash}"`), matching
`block-dangerous-git`.
2. **`replace_all`** keeps `// false | tostring` **inside** the filter.
`hook::jq_fields` wraps every filter in `// ""`, and jq's `//` treats
the boolean `false` as
empty — so a bare `.tool_input.replace_all` returns `""` where the old
call returned `"false"`.
   Verified against all three input shapes (absent / `false` / `true`):

   ```text
value=null filter=.tool_input.replace_all new=[] old=[false]
value=null filter=.tool_input.replace_all // false | tostring
new=[false] old=[false]
value=false filter=.tool_input.replace_all new=[] old=[false]
value=false filter=.tool_input.replace_all // false | tostring
new=[false] old=[false]
value=true filter=.tool_input.replace_all new=[true] old=[true]
value=true filter=.tool_input.replace_all // false | tostring new=[true]
old=[true]
   ```

**Failure semantics are unchanged in every hook.** `hook::jq_fields … ||
exit 0` lands on exactly
the skip the old empty-field guard produced — each hook's statement
right after its first old jq call
was already `[[ -n "$X" ]] || exit 0` or a `case … *) exit 0`.
`hook::require_jq` still runs first and
still makes a missing jq visible once per session.

**One trade stated plainly.** In `hardcoded-path-check` and
`secret-pattern-detection` the per-tool
content field is now serialized in the first call, i.e. BEFORE the
file-path exclusions and the
`git check-ignore` skip that used to precede it. On a skipped write that
is one extra copy out of jq
of a payload already buffered in memory, traded for one fewer process on
every path. Process
creation, not jq's parse, is the cost centre on the host this targets.

## Measurement

**Method.** Two checkouts — arm A at `origin/main`, arm B this branch —
with the arms **interleaved
inside one loop**, alternating which runs first each iteration, so both
arms share one load sample.
Compared as **paired deltas** (`B_i − A_i`), summarized by median and
quartiles. Never "50× A, then
50× B": one instrumented fork on this host has been recorded swinging 93
ms → 3234 ms, so a single
sequential before/after pair proves nothing.

**Machine load — every number below was taken under load, and is
labelled as such.** This box runs
several agents concurrently. Snapshot during the runs:
`cpu_pct_avg=20.3`, `procs_total=405`,
`bash_procs=18`, `free_mem_gb=31.4`. Windows 11, Git Bash (`GNU bash
5.3.15 x86_64-pc-cygwin`),
`jq-1.8.2`. Load is why absolute per-arm times below run into seconds;
it is also why the
**medians are inflated relative to a quiet box** and the conservative
statistics are the headline.

**Headline, conservative — p75 (least-favourable quartile) of the paired
deltas:**

| conversion shape | p75 | median | min-of-arms floor | NEW faster in |
| --- | --- | --- | --- | --- |
| 3 fields → 1 (run 1, N=100) | **-404 ms** | -1033 ms | — | 91/100 |
| 3 fields → 1 (run 2, N=100) | **-449 ms** | -991 ms | -394 ms | 95/100
|
| 2 fields → 1 (N=100) | **-194 ms** | -274 ms | -192 ms | 87/100 |

Run 1 was reproduced by run 2 to within 45 ms at p75 and 42 ms at the
median — the point the task
brief makes about a "PASS=154 FAIL=0" claim from a single run that did
not reproduce. Run 1's raw
samples were not retained to a file (its summary line is quoted above);
**runs 2 and the 2-field run
have every sample below**, and either alone carries the claim.

The p75 and the independently-computed floor (fastest observed A minus
fastest observed B, i.e. the
least-contended sample of each arm) agree to within 10 ms in both
shapes. Two conservative estimators
converging is the strongest claim here; the medians are the same effect
amplified by contention.

**End-to-end, whole-hook** — `block-noncanonical-commit.sh` invoked as a
process, N=60 interleaved:
**median paired delta -687 ms**, range -13039 ms to +11774 ms. Reported
deliberately even though it
is noisier and *smaller* than the isolated 3-field median: the parse
block cannot recover more than
the whole hook does, and omitting the weaker own-number is what makes a
stronger one look selected.

**Against the prior model.** A previous session's model predicted ~280
ms recovered and the handoff
recorded "the measured-versus-model gap says expect LESS." Stated
plainly: the conservative 2-field
number (**-194 ms**) is **under** that model, and the conservative
3-field number (**-404 ms**) is
**over** it. The model was a single figure for a range of shapes.

Every sample is in the collapsed sections below.

## Behavior verification

### Payload-level differential vs `origin/main` — 62/62 identical

Issue #1403 records that the previous extraction attempt (#1385)
regressed on **multi-line command
values** — four suites failed, all on multi-line payloads. That is the
exact risk class for this
change, so it is tested directly: the same payload fed to the
`origin/main` copy and the converted
copy of each hook, requiring **identical exit code, identical stdout and
identical stderr**.

Cases: plain command, backslash-newline continuation (`git commit
--no\<newline>verify`), multi-line
`-m` body, escaped quotes, embedded tab, PowerShell here-string,
stdout-redirect write, `gh pr
create`, empty command; Write/Edit/NotebookEdit multi-line content,
unmatched tool, empty content;
`replace_all` true/false; Workflow inline-script / `scriptPath`-only /
neither.

**Result: `DIFFERENTIAL PASS=62 FAIL=0`.** This is a deterministic
comparison of outputs, not a
timing measurement, so it does not carry the reproducibility caveat the
numbers above do.

### Contract suites — run STRICTLY one at a time

Their wall-clock assertions corrupt under contention, so the runner is
serial by construction.

**Re-run after the NUL fix** (this is the authoritative set; the pre-fix
tallies below it are kept
for the record). `lib/hook-utils.test.sh` is included because that is
where the helper and its new
regression case live.

```text
lib/hook-utils.test.sh               rc=0   PASS=155 FAIL=0
secret-pattern-detection             rc=0   PASS=44  FAIL=0
hardcoded-path-check                 rc=0   PASS=86  FAIL=0
skill-reference-verify               rc=0   PASS=96  FAIL=0
stale-path-verify                    rc=0   PASS=87  FAIL=0
block-noncanonical-commit            rc=0   passed: 202 failed: 0
```

`secret-pattern-detection` and `hardcoded-path-check` each gained
exactly **+2** assertions — the two
added by the NUL regression case in each file. That is visible directly
rather than by subtraction:
under mutation (the `split | join` reverted, tests kept) the same trees
report `PASS=42 FAIL=2` and
`PASS=155 → 154 FAIL=1`, failing on precisely those assertions and
nothing else.
`skill-reference-verify` reads higher than the pre-fix table below
because `main` was merged in
between; no case was added to it here.

**On the `block-noncanonical-commit` promise.** This description
previously said that suite "was
still running when this PR was opened" and that "its result will be
posted as a comment." No such
comment was ever posted, so it is settled here instead: the suite was
re-run after the NUL fix and
passes, **202/0**. Worth stating because it nearly went into this
description as a false negative —
that suite reports `passed: N failed: N`, not the `PASS=N FAIL=N` every
other guardrails suite uses,
so the first run's output filter matched nothing and the run looked like
an abort. It was not; the
filter was wrong. The tally above is from an unfiltered re-run.

**Not re-run, and why.** The remaining guardrails suites
(`block-hook-bypass`,
`block-convention-violation`, `flag-commit-pr-skill-bypass`,
`cli-flag-verify`,
`workflow-resilience-check`, plus the two already-converted git guards)
and the 15
non-guardrails plugins were not re-run for the NUL fix. The strip is a
no-op for any
payload without a NUL, and
`grep -rln 'hook::jq_fields' plugins/*/hooks/*.sh` returns guardrails
files only — the other 15
plugins carry the lib text and a version bump but have no call site.
Their pre-fix tallies stand.

**Pre-fix tallies** (the original `hook::jq_fields` conversion, before
the NUL fix):

```text
workflow-resilience-check            rc=0   PASS=16 FAIL=0               35s
block-convention-violation           rc=0   PASS=31 FAIL=0               320s
secret-pattern-detection             rc=0   PASS=42 FAIL=0               314s
flag-commit-pr-skill-bypass          rc=0   PASS=29 FAIL=0               303s
cli-flag-verify                      rc=0   PASS=52 FAIL=0               549s
skill-reference-verify               rc=0   PASS=68 FAIL=0               900s
hardcoded-path-check                 rc=0   PASS=84 FAIL=0               1501s
stale-path-verify                    rc=0   PASS=87 FAIL=0               1600s
stale-path-verify                    rc=0   PASS=87 FAIL=0               1571s
block-hook-bypass                    rc=0   PASS=260 FAIL=0              2278s
```

One caveat from that run, stated rather than hidden:
`hardcoded-path-check` and `stale-path-verify`
each show **two** lines because a background runner believed killed had
survived, so a second copy
of each ran concurrently. Both copies of both suites returned the same
tally. Contention can only
produce spurious *failures* in a wall-clock assertion, never a spurious
pass, so a green result
under contention is the stronger reading. (The first
`hardcoded-path-check` line's tally column is a
`grep` artifact — its log ends `PASS=84 FAIL=0`.)

<details><summary>Every sample — isolated parse block, 3 fields to 1
(N=100)</summary>

```text
payload=payload-ls.json iterations=100 fields=3
sample old_ms new_ms delta_ms
1 704 303 -401
2 1247 262 -985
3 648 223 -425
4 616 265 -351
5 650 252 -398
6 640 758 118
7 653 253 -400
8 627 223 -404
9 633 237 -396
10 1506 423 -1083
11 1664 481 -1183
12 1739 462 -1277
13 2018 1015 -1003
14 1852 796 -1056
15 838 958 120
16 1212 265 -947
17 704 260 -444
18 673 263 -410
19 722 258 -464
20 849 331 -518
21 1429 357 -1072
22 891 869 -22
23 1345 367 -978
24 1318 322 -996
25 663 807 144
26 1192 324 -868
27 1308 887 -421
28 1827 816 -1011
29 3450 782 -2668
30 6323 1474 -4849
31 8060 4150 -3910
32 7464 1999 -5465
33 3327 1541 -1786
34 1791 337 -1454
35 4003 869 -3134
36 11638 1056 -10582
37 4819 2220 -2599
38 5149 885 -4264
39 1270 858 -412
40 1215 799 -416
41 7450 1903 -5547
42 7951 1636 -6315
43 4620 4181 -439
44 4524 1083 -3441
45 2888 1642 -1246
46 2639 824 -1815
47 1870 276 -1594
48 1249 831 -418
49 1793 793 -1000
50 2296 251 -2045
51 2730 757 -1973
52 2241 1294 -947
53 5852 2494 -3358
54 5935 2120 -3815
55 3870 1551 -2319
56 5200 820 -4380
57 1205 265 -940
58 2331 242 -2089
59 4141 1333 -2808
60 1943 305 -1638
61 1910 328 -1582
62 1288 280 -1008
63 1252 266 -986
64 1202 271 -931
65 2896 253 -2643
66 4351 935 -3416
67 6699 894 -5805
68 2085 843 -1242
69 1278 291 -987
70 1218 251 -967
71 1220 258 -962
72 636 238 -398
73 1188 249 -939
74 674 749 75
75 617 223 -394
76 1170 253 -917
77 1751 222 -1529
78 4921 820 -4101
79 5530 796 -4734
80 1895 808 -1087
81 1953 838 -1115
82 1794 251 -1543
83 1193 295 -898
84 1723 809 -914
85 1713 269 -1444
86 1194 255 -939
87 652 769 117
88 1158 808 -350
89 4942 1893 -3049
90 4702 3793 -909
91 1890 1441 -449
92 1167 257 -910
93 1178 269 -909
94 649 269 -380
95 704 249 -455
96 1194 284 -910
97 1188 759 -429
98 1139 250 -889
99 2270 256 -2014
100 7879 1671 -6208
median_paired_delta_ms=-991 p25=-2014 p75=-449 (negative = NEW is faster)
iterations_where_NEW_faster=95/100
```

</details>

<details><summary>Every sample — isolated parse block, 2 fields to 1
(N=100)</summary>

```text
payload=payload-ls.json iterations=100 fields=2
sample old_ms new_ms delta_ms
1 2995 2056 -939
2 1664 268 -1396
3 1030 300 -730
4 491 285 -206
5 963 266 -697
6 484 793 309
7 427 253 -174
8 494 251 -243
9 432 741 309
10 414 249 -165
11 448 252 -196
12 434 230 -204
13 472 222 -250
14 949 225 -724
15 436 235 -201
16 441 235 -206
17 444 234 -210
18 444 250 -194
19 432 239 -193
20 446 234 -212
21 1021 251 -770
22 447 253 -194
23 485 256 -229
24 459 272 -187
25 1224 788 -436
26 1176 436 -740
27 6119 984 -5135
28 2840 1012 -1828
29 1183 1052 -131
30 552 287 -265
31 531 811 280
32 523 267 -256
33 479 808 329
34 463 266 -197
35 1016 269 -747
36 475 250 -225
37 477 269 -208
38 997 256 -741
39 469 259 -210
40 508 267 -241
41 1178 838 -340
42 1726 877 -849
43 5537 3620 -1917
44 4843 1005 -3838
45 2414 1547 -867
46 1708 271 -1437
47 1003 312 -691
48 450 281 -169
49 499 804 305
50 530 866 336
51 512 273 -239
52 1002 252 -750
53 468 295 -173
54 2921 926 -1995
55 1729 938 -791
56 1736 2165 429
57 1086 834 -252
58 484 957 473
59 1022 281 -741
60 1030 857 -173
61 2762 787 -1975
62 10295 1980 -8315
63 4781 3332 -1449
64 2104 968 -1136
65 1226 902 -324
66 1143 860 -283
67 1061 288 -773
68 1084 834 -250
69 1038 287 -751
70 466 259 -207
71 1215 815 -400
72 2466 4496 2030
73 3997 3347 -650
74 5594 1707 -3887
75 2973 2085 -888
76 1729 1418 -311
77 2138 1415 -723
78 978 252 -726
79 940 741 -199
80 973 261 -712
81 982 253 -729
82 1011 247 -764
83 456 794 338
84 1016 299 -717
85 1010 272 -738
86 1007 1324 317
87 2535 2307 -228
88 1007 266 -741
89 1013 822 -191
90 1011 831 -180
91 919 269 -650
92 956 241 -715
93 946 758 -188
94 1002 1804 802
95 2150 831 -1319
96 1590 1345 -245
97 2952 3790 838
98 6564 5253 -1311
      0 [main] bash 433945 dofork: child -1 - forked process 52068 died unexpectedly, retry 0, exit code 0xC0000142, errno 11
parsebench.sh: fork: retry: Resource temporarily unavailable
99 14516 9057 -5459
100 3374 1022 -2352
median_paired_delta_ms=-274 p25=-750 p75=-194 (negative = NEW is faster)
iterations_where_NEW_faster=87/100
```

</details>

<details><summary>Payload-level differential vs origin/main — all 62
cases</summary>

```text
ok:   block-hook-bypass  plain-ls  (rc=0)
ok:   block-hook-bypass  backslash-newline-continuation  (rc=0)
ok:   block-hook-bypass  multiline-m  (rc=0)
ok:   block-hook-bypass  escaped-quotes  (rc=0)
ok:   block-hook-bypass  embedded-tab  (rc=0)
ok:   block-hook-bypass  ps-herestring  (rc=0)
ok:   block-hook-bypass  redirect-write  (rc=2)
ok:   block-hook-bypass  gh-pr-create  (rc=0)
ok:   block-hook-bypass  empty-command  (rc=0)
ok:   block-noncanonical-commit  plain-ls  (rc=0)
ok:   block-noncanonical-commit  backslash-newline-continuation  (rc=0)
ok:   block-noncanonical-commit  multiline-m  (rc=2)
ok:   block-noncanonical-commit  escaped-quotes  (rc=0)
ok:   block-noncanonical-commit  embedded-tab  (rc=0)
ok:   block-noncanonical-commit  ps-herestring  (rc=2)
ok:   block-noncanonical-commit  redirect-write  (rc=0)
ok:   block-noncanonical-commit  gh-pr-create  (rc=0)
ok:   block-noncanonical-commit  empty-command  (rc=0)
ok:   block-convention-violation  plain-ls  (rc=0)
ok:   block-convention-violation  backslash-newline-continuation  (rc=0)
ok:   block-convention-violation  multiline-m  (rc=0)
ok:   block-convention-violation  escaped-quotes  (rc=0)
ok:   block-convention-violation  embedded-tab  (rc=0)
ok:   block-convention-violation  ps-herestring  (rc=0)
ok:   block-convention-violation  redirect-write  (rc=0)
ok:   block-convention-violation  gh-pr-create  (rc=0)
ok:   block-convention-violation  empty-command  (rc=0)
ok:   flag-commit-pr-skill-bypass  plain-ls  (rc=0)
ok:   flag-commit-pr-skill-bypass  backslash-newline-continuation  (rc=0)
ok:   flag-commit-pr-skill-bypass  multiline-m  (rc=0)
ok:   flag-commit-pr-skill-bypass  escaped-quotes  (rc=0)
ok:   flag-commit-pr-skill-bypass  embedded-tab  (rc=0)
ok:   flag-commit-pr-skill-bypass  ps-herestring  (rc=0)
ok:   flag-commit-pr-skill-bypass  redirect-write  (rc=0)
ok:   flag-commit-pr-skill-bypass  gh-pr-create  (rc=0)
ok:   flag-commit-pr-skill-bypass  empty-command  (rc=0)
ok:   hardcoded-path-check  write-multiline  (rc=0)
ok:   hardcoded-path-check  edit-multiline  (rc=0)
ok:   hardcoded-path-check  notebook-multiline  (rc=0)
ok:   hardcoded-path-check  unmatched-tool  (rc=0)
ok:   hardcoded-path-check  empty-content  (rc=0)
ok:   secret-pattern-detection  write-multiline  (rc=0)
ok:   secret-pattern-detection  edit-multiline  (rc=0)
ok:   secret-pattern-detection  notebook-multiline  (rc=0)
ok:   secret-pattern-detection  unmatched-tool  (rc=0)
ok:   secret-pattern-detection  empty-content  (rc=0)
ok:   cli-flag-verify  write-multiline  (rc=0)
ok:   cli-flag-verify  edit-multiline  (rc=0)
ok:   cli-flag-verify  unmatched-tool  (rc=0)
ok:   skill-reference-verify  write-multiline  (rc=0)
ok:   skill-reference-verify  edit-multiline  (rc=0)
ok:   skill-reference-verify  unmatched-tool  (rc=0)
ok:   stale-path-verify  write-multiline  (rc=0)
ok:   stale-path-verify  edit-multiline  (rc=0)
ok:   stale-path-verify  unmatched-tool  (rc=0)
ok:   skill-reference-verify  replace_all=true  (rc=0)
ok:   stale-path-verify  replace_all=true  (rc=0)
ok:   skill-reference-verify  replace_all=false  (rc=0)
ok:   stale-path-verify  replace_all=false  (rc=0)
ok:   workflow-resilience-check  workflow-inline-multiline  (rc=0)
ok:   workflow-resilience-check  workflow-scriptpath-only  (rc=0)
ok:   workflow-resilience-check  workflow-neither  (rc=0)
DIFFERENTIAL PASS=62 FAIL=0
```

</details>

## Review follow-up — the NUL fail-open (P1)

Review found a **fail-open this PR introduced**, and it reproduces.
`hook::jq_fields` delimits its
batched fields with a NUL byte. JSON may legitimately encode a NUL
inside a string, and a
`Write`/`Edit`/`NotebookEdit` `content` field is exactly where one
arrives — jq emitted the raw
byte, the read split that value in two, the cardinality check saw one
value too many, the helper
returned non-zero, and the hook's `|| exit 0` skipped detection
**entirely**. The per-field command
substitution this PR replaced discarded the NUL and scanned the rest, so
this was a regression, not
a pre-existing gap.

**Reproduction** — one payload, `tool_input.content` = `harmless first
line` + NUL +
`aws_key = AKIA…`, fed to `secret-pattern-detection.sh` at both refs:

| arm | exit | note |
| --- | --- | --- |
| `origin/main` | **2** (blocked) | stderr also carries bash's own
`warning: command substitution: ignored null byte in input` — the old
path saw the NUL, dropped it, and scanned the rest |
| this branch, before the fix | **0** (allowed) | secret passes
unblocked |
| this branch, after the fix | **2** (blocked) | |

**The framing scheme, and why this one.** Each value is now NUL-stripped
**inside the jq filter**
(`split("<NUL>") | join("")`, the 1-arity plain-string split — not
`gsub`, which would put a NUL
inside an Oniguruma pattern), so the delimiter provably cannot occur in
a value. The three options
weighed:

- **Length-prefix framing** is collision-proof but needs `read -N` (Bash
4.1+); this lib supports
  3.2+ and says so.
- **`@base64` / `@json` encoding** costs a decode per field shell-side —
a spawn each, which undoes
  the whole PR — and still cannot deliver the byte, see below.
- **Stripping** is not the lesser option, it is the **only
representable** one: a bash variable
cannot hold a NUL byte, so *no* scheme delivers one into
`HOOK_JQ_FIELDS`. It is also byte-for-byte
what the pre-conversion `$( )` did. Content **after** the NUL is
returned and scanned exactly as
  before.

**On "rather than failing open".** The mismatch policy is unchanged and
deliberately so: `return 1`
+ the caller's `|| exit 0` is the documented jq-absent fail-open
(`hook::require_jq` makes it visible
once per session) and matches the pre-conversion empty-field guard. What
changed is that the
**cause** of the spurious mismatch is gone — a mid-stream jq filter
error is now the only way to
trip it, exactly as on `main`.

**Regression cases** (all three go red on reverting the strip, green
with it):

- `lib/hook-utils.test.sh` — a NUL-bearing value keeps its slot and its
post-NUL content.
  Mutated: `PASS=154 FAIL=1`. Fixed: `PASS=155 FAIL=0`.
- `plugins/guardrails/hooks/secret-pattern-detection.test.sh` — a secret
**after** a NUL exits 2.
- `plugins/guardrails/hooks/hardcoded-path-check.test.sh` — a machine
path **after** a NUL exits 2.

Payloads are built with jq's `[0] | implode`, so no literal escape
sequence for the byte lives in
any test file's source.

**Blast radius.** The fix is in the synced shared lib, so
`scripts/sync-hook-utils.sh` ran and all
16 carrying plugins take a patch bump with an identical `### Fixed`
entry — the mechanism #1979 used
for the same file. `guardrails` additionally documents the guard-level
regression and the comment
softening below.

### Review nits — `replace_all` comment (both files)

`skill-reference-verify.sh` and `stale-path-verify.sh` now say the `//
false | tostring` is kept for
parity with the pre-conversion output, **not** because a branch depends
on it: every consumer tests
`== "true"`, which `""` and `"false"` fail alike. Comment only; behavior
unchanged.

## Checks run locally

- `shellcheck -x` clean on every changed `.sh` file (the ten hooks, the
shared lib, the
  three test files).
- `shfmt -d` clean on the same set.
- `npx --no-install markdownlint-cli2 plugins/guardrails/CHANGELOG.md` —
0 issues.
- `bash scripts/check-changelog-parity.sh --check-bump origin/main` —
passes
(`guardrails` `0.22.0` → `0.22.2` plus a patch bump on all 15 other
carrying plugins,
  each with its own new `## [<version>]` entry).
- `bash scripts/sync-hook-utils.sh --check` — all 16 plugin copies match
`lib/hook-utils.sh`;
  `--check-bump origin/main` — every carrying plugin bumped.
- No `printf '%s' "$INPUT" | jq` remains anywhere under
`plugins/guardrails/hooks/`.

## Related

- #2007 — introduced this helper's first two adopters
(`block-dangerous-git`, `block-no-verify`) and
set the pattern this PR follows; the `// "Bash"` shell-side default is
copied from it verbatim.
- #1979 — added `hook::jq_fields` to `lib/hook-utils.sh`. The review
follow-up above **does**
edit that shared lib (the NUL strip), so `scripts/sync-hook-utils.sh`
ran, all 16 plugin
copies were re-synced, and every carrying plugin took a patch bump — the
same mechanism
#1979 itself used. An earlier revision of this description claimed no
shared-lib edit; that
  is no longer true and is corrected here.
- #1403 — "recover the PreToolUse spawn-reduction work from the closed
#1385". This PR discharges
**one** part of it: the `hook::jq_fields` conversion across the
remaining guards, verified against
the multi-line regression class that sank #1385 (precondition 1,
differential above). It does
**not** discharge: `strip_quoted_spans` in
`flag-commit-pr-skill-bypass`, the deferred
`git rev-parse --is-inside-work-tree` probe in `hardcoded-path-check`,
committed multi-line
regression **cases in the suites** (precondition 3 — the differential
here is a working harness,
not committed test coverage), or the unreviewed
`hook_latency_report.py`. Left open.
- #1414 — CI has no Windows runner, so a green `plugin-gate` carries no
signal for a change whose
whole point is MSYS fork-emulation cost. That is why this PR carries
local Windows measurements
  and a payload-level differential rather than leaning on CI.

## Reproducing the numbers

The harnesses are scratch scripts, not committed. To re-derive: clone
`origin/main` and this branch
side by side, then for each iteration time one invocation of each arm
back to back (alternating
order), and take the median/p75 of `B_i − A_i`. State the machine load
with any number produced —
on a quiet box the absolute times will be far lower than those above,
and the recovery should land
nearer the min-of-arms floor (-394 ms for 3 fields, -192 ms for 2) than
the loaded medians.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

guardrails: PreToolUse guards fail open — killed at their declared timeout on 86% of runs

1 participant