From 7c21cdc504ce0d208574026a2911b3e636e7b637 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:31:17 -0400 Subject: [PATCH 1/9] fix(guardrails): make the drive-root /tmp guard see a Write, not only a command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit block-windows-drive-tmp exists to stop a Windows write landing at :\tmp (#2594). On 2026-08-30 it missed one: an empty C:\tmp\tmp.rSFIkHm5DO was created and nothing fired. Root cause: the hook read `.tool_input.command`. A Write payload carries `file_path` instead, so hook::jq_fields produced an empty COMMAND and the `[[ -n "$COMMAND" ]] || exit 0` early exit returned before any matcher ran. The guard covered command-shaped writes and not tool-shaped ones. Two edits, because either alone is inert. The script now also reads `.tool_input.file_path` and `.tool_input.notebook_path`, and the early exit tests both doors; hooks.json gains a SEPARATE PreToolUse group matching `Write|Edit|MultiEdit|NotebookEdit`, without which the hook would never receive those payloads. A separate group, not a widened matcher string: widening the existing Write|Edit|NotebookEdit group would hand MultiEdit to secret-pattern-detection and hardcoded-path-check, and widening Bash|PowerShell would attach seven command-lane guards to every file write. The file-path lane reuses the shipped has_drive_root_tmp() — no second matcher — and carries none of the command lane's write-shape inference, because on Write/Edit the path IS the write target. Fail-closed posture is unchanged and now covers the new field: NUL handling, buffer_stdin rc 2, jq absence and MAX_COMMAND_LEN all behave as before. Only PATH fields were added to the jq_fields call; content fields are deliberately excluded so a NUL in a file body cannot make this guard fire. No length ceiling on the path lane, by decision: that lane runs three EREs with no tokenization, so length creates no parse ambiguity and a ceiling would only add a false-positive class. Case folding moves from `printf | tr` to `${var,,}`, removing a fork and an exec (~280 ms on Windows Git Bash) from the shared normalizer — a cost the pre-existing per-Bash-call lane was already paying. ADR 0003 measurement, in the README: 204 distinct real Write/Edit/MultiEdit/ NotebookEdit target paths from 202 local session transcripts on a Windows host; 1 finding (0.49% firing) and it was the incident itself, so precision is 100%; 6/6 seeded spellings detected end to end. Hook-budget share is measured and recorded in the README, load-normalized against an interleaved `bash -c :` baseline because the measuring host was under heavy agent load. Repro-first per the hook-precision discipline: the four seeded Write payloads exit 0 against the unmodified hook and 2 against this one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- .../data/block-windows-drive-tmp.schema.json | 6 +- docs/conventions/windows-path-emit/README.md | 9 +- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 66 ++++++++++++ plugins/guardrails/README.md | 102 +++++++++++++++++- .../hooks/block-windows-drive-tmp.sh | 88 +++++++++++++-- .../hooks/block-windows-drive-tmp.test.sh | 81 ++++++++++++++ plugins/guardrails/hooks/hooks.json | 11 ++ 8 files changed, 345 insertions(+), 20 deletions(-) diff --git a/docs/conventions/hook-telemetry/data/block-windows-drive-tmp.schema.json b/docs/conventions/hook-telemetry/data/block-windows-drive-tmp.schema.json index dfd88fd24..0bf5f710c 100644 --- a/docs/conventions/hook-telemetry/data/block-windows-drive-tmp.schema.json +++ b/docs/conventions/hook-telemetry/data/block-windows-drive-tmp.schema.json @@ -9,15 +9,15 @@ "properties": { "tool": { "type": "string", - "description": "The invoking tool — \"Bash\" or \"PowerShell\"; this guard matches both." + "description": "The invoking tool — \"Bash\" or \"PowerShell\" on the command lane, or \"Write\" / \"Edit\" / \"MultiEdit\" / \"NotebookEdit\" on the file-path lane; this guard matches all of them." }, "subject": { "type": "string", - "description": "Privacy-safe command subject: `Bash:` for a Bash call, with leading sudo / env-assignment prefixes stripped and the token basenamed; the bare tool name (`PowerShell`) for a PowerShell call, which is not tokenized. NEVER the full command or its arguments." + "description": "Privacy-safe command subject: `Bash:` for a Bash call, with leading sudo / env-assignment prefixes stripped and the token basenamed; the bare tool name (`PowerShell`, `Write`, `Edit`, `MultiEdit`, `NotebookEdit`) for every other tool, which is not tokenized. NEVER the full command, the written file path, or their arguments." }, "form": { "type": "string", - "description": "The matched form when blocked: \"redirect\" | \"write-utility\" | \"too-long\" (command exceeded the parse cap and was blocked fail-closed). Empty string when the command was allowed (status ok)." + "description": "The matched form when blocked: \"redirect\" | \"write-utility\" | \"too-long\" (command exceeded the parse cap and was blocked fail-closed) | \"file-path\" (a Write/Edit/MultiEdit/NotebookEdit target path was a drive-root temp path). Empty string when the call was allowed (status ok)." } } } diff --git a/docs/conventions/windows-path-emit/README.md b/docs/conventions/windows-path-emit/README.md index 55b2dd187..22cfafeeb 100644 --- a/docs/conventions/windows-path-emit/README.md +++ b/docs/conventions/windows-path-emit/README.md @@ -185,10 +185,11 @@ one costs a follow-up. Promote it when there is precision to point at. This is a different concern from [`plugins/guardrails/hooks/block-windows-drive-tmp.sh`](../../../plugins/guardrails/hooks/block-windows-drive-tmp.sh), -which blocks a *command* aimed at a drive-root temp path before it runs (#2594). That guard reads a -command string ahead of time; this detector reads the filesystem afterwards, and catches the class -where the offending path was never spelled in a command at all — it was computed inside a native -interpreter. +which blocks a *tool call* aimed at a drive-root temp path before it runs (#2594). That guard reads +the payload ahead of time — a Bash/PowerShell command string, and since guardrails 0.30.0 a +Write/Edit/MultiEdit/NotebookEdit target path as well; this detector reads the filesystem +afterwards, and catches the class where the offending path was never spelled in the payload at all — +it was computed inside a native interpreter. It is also outside the charter of [`scripts/check-shell-portability.sh`](../../../scripts/check-shell-portability.sh), whose token list diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 4cea2f0f1..eee79c519 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.29.22" + "version": "0.30.0" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 774d281b0..9a6e3fb88 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,72 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.30.0] + +### Fixed + +- **`block-windows-drive-tmp` now sees a `Write`, not only a command.** The guard + exists to stop a Windows drive-root temp write (#2594), and it missed one: on + 2026-08-30 an empty `C:\tmp\tmp.rSFIkHm5DO` was created and nothing fired. The + hook read `.tool_input.command`; a `Write` payload carries `file_path` instead, + so `hook::jq_fields` produced an empty `COMMAND` and the `[[ -n "$COMMAND" ]]` + early exit returned before any matcher ran. The guard covered command-shaped + writes and not tool-shaped ones. It now also reads `.tool_input.file_path` and + `.tool_input.notebook_path`, and the early exit tests both doors. Verified + repro-first: the four seeded spellings (`C:\tmp\…`, `/tmp/…`, `/c/tmp/…`, + `C:/tmp/…`) exit 0 against the unmodified hook and 2 against this one. + +### Added + +- **A `Write | Edit | MultiEdit | NotebookEdit` matcher registration for the same + guard.** The script change alone would have been inert: without the second + `hooks.json` registration the hook never receives those payloads. It is a + SEPARATE PreToolUse group, not a widening of the existing `Write|Edit| + NotebookEdit` matcher string, so `secret-pattern-detection` and + `hardcoded-path-check` do not silently acquire `MultiEdit`, and not a widening + of `Bash|PowerShell`, which would have attached seven command-lane guards to + every file write. A tool name matches one group, so the hook still fires once + per tool call. +- **A `file-path` telemetry form**, alongside `redirect` / `write-utility` / + `too-long`. The privacy floor is unchanged: `subject` is the bare tool name on + this lane (`Write`), and the target path never reaches the envelope. + +### Changed + +- **Both doors feed one matcher.** The file-path lane calls the shipped + `has_drive_root_tmp()` — there is no second matcher — so every spelling the + command lane blocks and every one it permits (`%TEMP%` expansions, `/var/tmp`, + `./tmp`, `foo/tmp`, `/tmpdir`, `C:/tmp2`, UNC `\\server\tmp`) decides + identically on a `Write`. The lane needs none of the command lane's inference: + on `Write`/`Edit` the path IS the write target, so there is no redirect to + parse, no producer-utility whitelist, and no segment splitting. +- **Case folding is pure shell.** `printf | tr` was a fork AND an exec (~280 ms + together on Windows Git Bash) to fold one character class; `${var,,}` does the + same work in-process. That removes two spawns from the existing per-Bash-call + cost as well, and keeps the new per-Write lane from adding them. +- **The command lane is skipped outright on a file-path payload.** + `has_redirect_to_drive_root_tmp` runs `mask_quoted_redirect_ops` in a command + substitution, and forking to scan an empty string would be per-Write budget + spent to reach a foregone answer. + +### Notes + +- **Fail-closed posture is unchanged and now covers the new field.** NUL-byte + handling, `hook::buffer_stdin` rc 2, jq absence and `MAX_COMMAND_LEN` all + behave exactly as before; a NUL in `file_path` fails closed by the same + already-shipped check, because only PATH fields were added to the + `hook::jq_fields` call. Content fields (`content` / `new_string` / + `new_source`) are deliberately NOT requested: `HOOK_JQ_FIELDS_NUL` is computed + across every requested field, so reading them would make this guard block on a + NUL anywhere in a file body — hardcoded-path-check's concern, not this one's. +- **No length ceiling on the file-path lane, by decision.** `MAX_COMMAND_LEN` + exists because the command lane walks its string character by character twice + before matching; the path lane runs three EREs with no tokenization, detection + does not degrade with length, and a blocking ceiling would only add a + false-positive class. The payload stays bounded by `hook::buffer_stdin`. +- **Measured budget share** for the widened surface is recorded in the README's + hook-budget accounting. + ## [0.29.22] ### Fixed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 2d4e60727..b1a21d5dd 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -29,7 +29,7 @@ Each guard is independently toggleable, so you run exactly the subset you want. | **block-no-verify** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Git hook-bypass attempts on `git commit` / `git push`: `--no-verify` / `-n`, `core.hooksPath=` assignment, and hook-manager disable env vars, a configurable prefix set defaulting to `lefthook`, `husky`, `pre_commit`, `simple_git_hooks` (e.g. `LEFTHOOK=0`, `HUSKY=0`, `PRE_COMMIT_*=false`), tunable via `block_no_verify_hook_manager_prefixes`, including inside compound `cd … && …` commands. | | **block-dangerous-git** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Irreversible git operations: `push --force`/`-f` plus the equivalent leading-`+` refspec and `--mirror` forms, and the unsafe `--force-with-lease` spellings, in the two kinds git itself treats differently. **No expected value** (bare `--force-with-lease` or `=`) leases against the remote-tracking ref, which git documents as "trivially defeated" by a background fetch, blocked unless `--force-if-includes` is present, which git documents as the mitigation for exactly this form. **A movable `=:`**, such as `origin/main`, `HEAD`, a tag, an *abbreviated* object id, or hex of the wrong width for this repository's hash format, all of which git resolves at push time, and gitrevisions resolves a short hex word as a ref before trying it as an object-id prefix, is blocked unconditionally, because git declares `--force-if-includes` a no-op alongside an explicit `:`. A lease passes only when `` is immutable: a **literal** object id of the pushed repository's own hash width (detection never evaluates substitutions, so resolve it with `git rev-parse` as a separate step and pass the result) (40 hex under SHA-1, 64 under SHA-256, read from `git rev-parse --show-object-format` with the command's own `-C`/`--git-dir`/`--work-tree`/`--namespace` replayed onto it; undeterminable fails closed) or the empty string asserting the ref must not exist. The other width is a ref name there, not an object id. git ignores a ref whose name is full-width hex for its own format, but resolves one of the other width like any name. git scopes a pin to its own ref, so a bare fallback alongside a pinned entry still governs every other ref being updated; where the same ref carries several lease entries, git consults the first, and so does this guard. A trailing `--no-force-with-lease` cancels every previous lease, and a push dry-run disarms the check. Also blocked: `reset --hard`, `clean` with a force flag (any dry-run flag disarms), worktree-wide `checkout`/`restore` pathspecs (`.`, `:/`, `:(top…)`; path-scoped forms and `restore --staged .` pass), and forced `checkout -f` / `switch --discard-changes`. Accepted unique-prefix abbreviations of the blocked long options match too. `branch -D` is deliberately not blocked (reflog-recoverable; sanctioned skill flows issue it). Per-repo/per-user allow-list via the `block_dangerous_git_allow` userConfig option (comma list, any subset of `push-force,push-lease-unsafe,reset-hard,clean-force,checkout-dot,restore-dot,checkout-force`). | | **block-hook-bypass** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Bash file-write workarounds that circumvent the Write/Edit hook gates: `cat > file`, `echo … > file`, inline python code with file-write indicators (`python`/`python3`/`py`/`pypy`, with `-c` or reading the program from stdin as `python3 - <:\tmp` instead of `%TEMP%` and accumulate at the volume root. Redirects and write utilities (`mkdir`/`mktemp`/`tee`/`cp`/`Set-Content`/`Out-File`/…) are blocked with a redirect-to-`%TEMP%` message. Does not fire on non-Windows hosts; leaves `%TEMP%` / `$TEMP` / `$TMPDIR` / `$env:TEMP` / `/var/tmp` alone. | +| **block-windows-drive-tmp** | PreToolUse · Bash \| PowerShell **and** Write \| Edit \| MultiEdit \| NotebookEdit | **Blocks** (exit 2) | Windows-only: write targets that are a drive-root temp path: POSIX `/tmp`, MSYS `/c/tmp`, `C:\tmp`, or drive-root `\tmp`, which resolve to `:\tmp` instead of `%TEMP%` and accumulate at the volume root. On the **command lane** redirects and write utilities (`mkdir`/`mktemp`/`tee`/`cp`/`Set-Content`/`Out-File`/…) are blocked; on the **file-path lane** (since **0.30.0**) the tool's own `file_path` / `notebook_path` is matched directly, because on a Write the path *is* the write target. Both lanes call one matcher, so the same spellings block and the same ones pass. Does not fire on non-Windows hosts; leaves `%TEMP%` / `$TEMP` / `$TMPDIR` / `$env:TEMP` / `/var/tmp`, relative `./tmp`, `foo/tmp`, `/tmpdir`, `C:/tmp2` and UNC `\\server\tmp` alone. | | **block-exported-msys-pathconv** | PreToolUse · Bash \| PowerShell | **Blocks** (exit 2) | Windows-only: an **exported** `MSYS_NO_PATHCONV` / `MSYS2_ARG_CONV_EXCL` (also the `declare -x` / `typeset -x` spellings), which switches off MSYS argv rewriting for every *later* command in the same command string. A later path argument then reaches a Windows-native program unconverted and git resolves its leading `/` against the current drive, so `git worktree add /d/worktrees/x` creates `:\d\worktrees\x` (#2870). Deliberately keys on the environment, not on a path shape: the incident command's path argument was identical to one that had already worked. A prefix whose command word is a **shell** (`MSYS_NO_PATHCONV=1 bash -c '…'`, `env … sh -c '…'`) blocks too: the prefix scopes to one *process*, and when that process is an interpreter, one process is every command inside it. A prefix on a **non-shell** command word (`MSYS_NO_PATHCONV=1 git show …`) and a bare assignment are not matched. The first scopes to exactly that command, and the second has no effect at all because the MSYS runtime reads the environment. Does not fire on non-Windows hosts. | | **cli-flag-verify** | PostToolUse · Write \| Edit | **Advisory** (exit 0) | Hallucinated CLI flags: a `--flag` written as a command that does not exist in the binary's actual `--help` output. Surfaces via `additionalContext`, never blocks. | | **workflow-resilience-check** | PreToolUse · Workflow | **Advisory** (exit 0) | Un-throttled Workflow fan-out: a script calling `parallel()` / `pipeline()` with no wave-cap throttle (`inWaves` / `inWavesPipeline`) and no retry wrapper (`agentRetry`), which risks a burst 529 under wide Opus fan-out. Surfaces a resilience checklist via `additionalContext`, never blocks. **Opt-in. Default off since 0.20.0** (behavioral-class injector config-disabled per #2021; set `workflow_resilience_check_enabled=true` to enable). | @@ -224,6 +224,104 @@ out of scope until such a signal exists. `trailer_policy` of `none`, so demanding it would block the skill's own conformant output in repos whose convention forbids co-author trailers. +- **`block-windows-drive-tmp` guards two doors with one matcher, and only one of + them existed before 0.30.0.** A write reaches the drive root either as a + command string (`echo x > /tmp/f`) or as a tool's own target path (`Write` + with `file_path: C:\tmp\f`). The hook read `.tool_input.command` only, so the + second shape hit an empty-`COMMAND` early exit and passed unexamined — a real + `C:\tmp\tmp.rSFIkHm5DO` was created on 2026-08-30 with no guard firing. Both + shapes now feed the shipped `has_drive_root_tmp()`; there is no second matcher + to drift. The file-path lane carries **none** of the command lane's + string-matching floor, because it needs none: on `Write`/`Edit` the path is + the write target by construction, so there is no redirect to parse, no + producer-utility whitelist, and no quoted-prose ambiguity. Its residual is + narrower than the command lane's and of a different kind: a path assembled at + runtime and passed by a tool this guard does not match — an MCP file-write + tool, or a Bash form the command lane's own residuals already allow. +- **`block-windows-drive-tmp`'s file-path lane shipped blocking on a measured + sweep, per [ADR 0003](../../docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.md).** + Corpus: 204 distinct `file_path` / `notebook_path` values that a real `Write`, + `Edit`, `MultiEdit` or `NotebookEdit` actually carried across 202 local Claude + Code session transcripts on a Windows host — the lane's real deployment + surface, absolute Windows and MSYS paths rather than repo-relative ones a + drive-root matcher could never match. **1 finding in 204 (0.49% firing), and + it was a true positive**: `/tmp/tmp.rSFIkHm5DO/worktree-root`, the very write + that produced the `C:\tmp\tmp.rSFIkHm5DO` this lane exists to stop. Precision + 100% (1/1); the near-misses that make a zero informative — `%TEMP%` paths, + `/var/tmp`, `docs/tmp`, `./tmp`, `foo/tmp`, `/tmpdir`, `C:/tmp2`, UNC + `\\server\tmp` — are all in the corpus or the contract suite and none fired. + Six seeded spellings were detected end-to-end. Compare the ADR's shipped + reference guard, promoted at 0.51% firing and 57% precision. +- **`block-windows-drive-tmp` reads the payload's PATH fields, never its + content.** `.tool_input.content` / `.new_string` / `.new_source` are + deliberately not requested. `HOOK_JQ_FIELDS_NUL` is computed across every + requested field, so pulling written content in would make this guard fail + closed on a NUL anywhere in a file body — that surface belongs to + `hardcoded-path-check` and `secret-pattern-detection`. A prose mention of + `/tmp` inside a written file is therefore never a block. +- **`block-windows-drive-tmp` puts no length ceiling on a file path, and that is + a decision.** `MAX_COMMAND_LEN` (16384) fails the command lane closed because + that lane walks its string character by character twice before matching + anything, so past some length the guard genuinely cannot say what would run. + The file-path lane runs three EREs over one string with no tokenization: a + drive-root prefix matches at any length, so length creates no parse ambiguity + and a blocking ceiling would only refuse legitimate long paths. The payload as + a whole stays bounded by `hook::buffer_stdin`, whose stall path fails closed. + +### Hook budget accounting + +Per [`docs/conventions/hook-budget/README.md`](../../docs/conventions/hook-budget/README.md) +rule 1, widening an always-on hook's matcher states its measured share of the +fleet budget. `block-windows-drive-tmp` moved from `Bash|PowerShell` to that set +plus `Write|Edit|MultiEdit|NotebookEdit` in **0.30.0**, so the surface that +changed is the **per-`Write` tool call**, whose ceiling is ≤ 1 s typical / +≤ 2 s worst-case. + +**Method** (the convention's, unchanged): `EPOCHREALTIME` wall-clock around +direct hook invocation with a benign representative payload — a `Write` of a +short body to an ordinary repo path — sets launched concurrently (`&` + `wait`) +to approximate the harness's parallel dispatch. Windows 11 + Git Bash, +2026-08-30. + +**Host condition, stated because it changes how these numbers must be read.** +The measuring host was under heavy concurrent agent load: its `bash -c :` spawn +baseline measured **4,498 ms** against the convention's reference-host **≈ 80 ms**, +roughly 56× slower. Absolute milliseconds from this host are therefore not +comparable to the convention's figures. Every measurement below re-measures +`bash -c :` **interleaved with each trial** and reports the load-normalized +ratio (hook wall ÷ same-trial spawn baseline); the reference column converts +that ratio back at 80 ms. The spawn-equivalent figure is the stable one. + +| Measured (n=12 interleaved trials) | spawn-equivalents | @ 80 ms reference host | +| --- | --- | --- | +| `block-windows-drive-tmp` alone, one `Write` payload | 6.31 | ≈ 505 ms | +| guardrails per-`Write` PreToolUse set BEFORE (2 hooks, concurrent) | 12.59 | ≈ 1,007 ms | +| guardrails per-`Write` PreToolUse set AFTER (3 hooks, concurrent) | 12.34 | ≈ 987 ms | + +A separate **paired A/B** (n=15, BEFORE and AFTER launched back to back inside +each trial in alternating order, so load drift biases both arms equally) put the +set wall at a mean **1.26×** after the addition, with per-trial ratios spanning +0.55×–1.82×. Read the two together honestly: **the added hook costs ≈ 0.5 s of +reference-host work of its own, and because the harness dispatches matching +hooks in parallel the set wall it joins grows by somewhere between nothing +measurable and ≈ 26%** — the wall is set by the slowest member, and on an +unloaded host this hook is not that member. The spread is the host's, not the +hook's, and a re-measurement on a quiet reference box would narrow it. + +**Share of the budget, and the overage.** The convention's per-tool-call ceiling +is ≤ 1 s typical / ≤ 2 s worst-case, and the fleet's binding per-`Write` +accounting there is **≈ 1.9 s** for the whole always-on set (two formatters plus +three guardrails verifiers) — already over the typical ceiling before this +change. The guardrails PreToolUse slice of that measured ≈ 1.0 s reference-host +here; this widening takes it to ≈ 1.0–1.3 s, so the increment is **≈ 0–0.3 s, +roughly 0–15% of the ≤ 2 s worst-case ceiling**, landing on a surface that is +already in overage. Per the convention's rule 2 the budget does not relax to +absorb that: the overage is guardrails' own spawn-reduction work (#1403), and +this change pays part of its way by removing the `printf | tr` fork+exec pair +from the shared normalizer — a cost the pre-existing per-Bash-call lane was +paying on every call. Operators who cannot afford the increment have the +per-hook kill switch below. + ## Per-hook kill switches Each guard is toggled by its own `userConfig` boolean, default **on**, except the two @@ -383,7 +481,7 @@ reads it from. | `block_no_verify_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_NO_VERIFY_ENABLED` | Block git hook-bypass attempts (--no-verify, core.hooksPath=, hook-manager env-var disables for a configurable set — lefthook/husky/pre-commit/simple-git-hooks by default) | | `block_dangerous_git_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_DANGEROUS_GIT_ENABLED` | Block irreversible git operations (push --force, push --force-with-lease leasing against a value git resolves at push time — either no expected value, or an expectation that is not an object id of the repository's own hash width — reset --hard, clean -f, worktree-wide checkout/restore discards) | | `block_hook_bypass_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_ENABLED` | Block Bash file-write workarounds that circumvent Write/Edit hook gates | -| `block_windows_drive_tmp_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_WINDOWS_DRIVE_TMP_ENABLED` | Block Bash/PowerShell writes whose target is a Windows drive-root temp path (/tmp, C:\tmp, \tmp, /c/tmp) that resolves to :\tmp instead of %TEMP% | +| `block_windows_drive_tmp_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_WINDOWS_DRIVE_TMP_ENABLED` | Block writes whose target is a Windows drive-root temp path (/tmp, C:\tmp, \tmp, /c/tmp) that resolves to :\tmp instead of %TEMP% — both Bash/PowerShell commands and Write/Edit/MultiEdit/NotebookEdit file paths. One switch covers both lanes | | `block_exported_msys_pathconv_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_EXPORTED_MSYS_PATHCONV_ENABLED` | Block a leaking MSYS path-conversion suppressor on Windows: an EXPORTED MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL, or a prefix on a child shell (MSYS_NO_PATHCONV=1 bash -c ...). Either switches off conversion for later commands, letting an unconverted /d/... reach git as :\d\...; a prefix on a non-shell command word and a bare assignment are not matched | | `block_noncanonical_commit_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ENABLED` | Block `git commit -m` when the message actually contains a newline (multi-line `-m` mangles across shells — pipe it via `-F -` instead; single-line `-m` passes); --amend, -C/-c, --fixup/--squash, -F , and an in-progress merge/rebase are exempt | | `block_convention_gate_enabled` | boolean | `true` | `CLAUDE_PLUGIN_OPTION_BLOCK_CONVENTION_GATE_ENABLED` | Block a commit subject or `gh pr create --title` that violates the team-tracked convention pattern in .claude/source-control.md (no tracked pattern = no enforcement; same exemptions as block-noncanonical-commit) | diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index c3b84ab71..a6ddae92d 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -1,6 +1,16 @@ #!/usr/bin/env bash # PreToolUse hook: block writes whose target is a Windows drive-root temp path. -# Triggered on Bash and PowerShell tool calls. +# Triggered on Bash and PowerShell tool calls (a command string), and on +# Write / Edit / MultiEdit / NotebookEdit tool calls (a file path). +# +# TWO DOORS, ONE MATCHER. A write reaches the drive root through either shape, +# and until 0.30.0 only the command shape was inspected: the hook read +# `.tool_input.command`, a `Write` payload carries `file_path` instead, so the +# empty-COMMAND early exit returned before any matcher ran and an empty +# `C:\tmp\tmp.rSFIkHm5DO` was created with no guard noticing. Both doors now feed +# the SAME has_drive_root_tmp() matcher. The file-path lane needs none of the +# command lane's inference — no redirect parsing, no write-utility whitelist, +# no segment splitting — because on Write/Edit the path IS the write target. # # On Windows (Git Bash / MSYS / Cygwin), a hardcoded POSIX `/tmp` path resolves to # `:\tmp` (e.g. `C:\tmp`) rather than the platform temp directory @@ -59,8 +69,16 @@ INPUT=$(hook::buffer_stdin) || { # absence — same posture as the other Bash/PowerShell blocking guards (#2146). hook::require_jq_blocking "guardrails-block-windows-drive-tmp" "block_windows_drive_tmp_enabled" +# Path fields only — never `.tool_input.content` / `.new_string` / `.new_source`. +# HOOK_JQ_FIELDS_NUL is computed across every REQUESTED field, so pulling the +# written CONTENT in here would make this guard block on a NUL anywhere in a +# file body: a false-positive class that is hardcoded-path-check's concern, not +# this guard's. `notebook_path` rides along in the same jq process (one spawn, +# not two) because NotebookEdit spells its target differently from Write/Edit. jq_rc=0 -hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || jq_rc=$? +hook::jq_fields "$INPUT" \ + '.tool_input.command' '.tool_name' \ + '.tool_input.file_path' '.tool_input.notebook_path' || jq_rc=$? if ((jq_rc == 2)); then echo "BLOCKED: the hook payload could not be parsed." >&2 exit 2 @@ -69,15 +87,23 @@ fi # A NUL byte in EITHER field is fail-CLOSED (#2136 / #2122). if ((HOOK_JQ_FIELDS_NUL)); then - echo "BLOCKED: the payload carries a NUL byte, which a command cannot reliably carry." >&2 + echo "BLOCKED: the payload carries a NUL byte, which neither a command nor a file path can reliably carry." >&2 echo "What a guard can read is not dependably what would run, so this is refused rather than matched." >&2 echo "Fix: reissue the tool call without the embedded NUL." >&2 exit 2 fi COMMAND="${HOOK_JQ_FIELDS[0]}" -[[ -n "$COMMAND" ]] || exit 0 TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}" +# Write / Edit / MultiEdit spell the target `file_path`; NotebookEdit spells it +# `notebook_path`. Reading both and taking whichever is populated keeps the lane +# correct without depending on which spelling a given tool version emits. +FILE_PATH="${HOOK_JQ_FIELDS[2]:-}" +[[ -n "$FILE_PATH" ]] || FILE_PATH="${HOOK_JQ_FIELDS[3]:-}" + +# Neither door carried anything to inspect. Before 0.30.0 this exit tested +# COMMAND alone, which is exactly how a `Write` payload passed unexamined. +[[ -n "$COMMAND" || -n "$FILE_PATH" ]] || exit 0 # Non-Windows hosts: /tmp is the real POSIX temp. Skip entirely. Tests force # OSTYPE=msys to exercise the Windows lane on Linux CI. @@ -112,14 +138,35 @@ block() { } # Above this length the command is not parsed — fail closed (same ceiling as the -# other argv-faithful Bash guards). +# other argv-faithful Bash guards). The ceiling exists because the COMMAND lane +# below walks the string character by character twice (mask_quoted_redirect_ops, +# split_shell_segments) before it matches anything. NO EQUIVALENT CEILING GUARDS +# THE FILE-PATH LANE, and that is a decision rather than an omission: that lane +# runs three EREs against one string with no tokenization, so length buys no +# parse ambiguity there, and detection does not degrade with length — a +# drive-root prefix matches at any total length. A blocking ceiling would only +# add a false-positive class (a legitimate long path refused for its size). The +# payload as a whole is still bounded upstream by hook::buffer_stdin's idle +# timeout, which fails CLOSED on a truncated read. if ((${#COMMAND} > MAX_COMMAND_LEN)); then block "too-long" fi # Slash-normalize so C:\tmp, C:/tmp, and \tmp share one matcher. Lowercase for # case-insensitive Windows path compare without relying on bash [[ =~ ]] flags. -NORM=$(printf '%s' "${COMMAND//\\//}" | tr '[:upper:]' '[:lower:]') +# Pure shell, no `printf | tr`: that pipeline is a fork AND an exec (~280 ms +# together on Windows Git Bash) to fold one character class, and this guard now +# fires on the per-Write surface as well, where that pair would be pure added +# budget. Same bytes for ASCII paths and commands, which is all a drive-root +# matcher reads. Result lands in NORM_OUT rather than on stdout because a +# command substitution would fork the shell right back. +norm_lower() { + local s="${1//\\//}" + NORM_OUT="${s,,}" +} + +norm_lower "$COMMAND" +NORM="$NORM_OUT" # True when carries a drive-root tmp path reference: # /tmp[/...] — POSIX form (Git Bash maps this to :\tmp) @@ -317,12 +364,33 @@ has_write_utility_with_drive_root_tmp() { return 1 } -if has_redirect_to_drive_root_tmp "$NORM"; then - block "redirect" +# --- File-path lane: Write / Edit / MultiEdit / NotebookEdit ----------------- +# On these tools the payload's path IS the write target, so the whole +# write-shape inference the command lane needs — redirect parsing, the producer +# utility whitelist, per-segment splitting — is structurally absent here. The +# matcher is the shipped has_drive_root_tmp(), unchanged and unduplicated, so +# every spelling the command lane blocks (POSIX /tmp, MSYS /c/tmp, C:\tmp, +# drive-root \tmp) and every one it permits (%TEMP% expansions, /var/tmp, +# ./tmp, foo/tmp) decide identically on this lane. +if [[ -n "$FILE_PATH" ]]; then + norm_lower "$FILE_PATH" + if has_drive_root_tmp "$NORM_OUT"; then + block "file-path" + fi fi -if has_write_utility_with_drive_root_tmp "$NORM"; then - block "write-utility" +# --- Command lane: Bash / PowerShell ----------------------------------------- +# Skipped outright on a file-path payload: has_redirect_to_drive_root_tmp runs +# mask_quoted_redirect_ops in a command substitution, and forking the shell to +# scan an empty string would be per-Write budget spent to reach a foregone `no`. +if [[ -n "$COMMAND" ]]; then + if has_redirect_to_drive_root_tmp "$NORM"; then + block "redirect" + fi + + if has_write_utility_with_drive_root_tmp "$NORM"; then + block "write-utility" + fi fi emit_tel "ok" "" diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index 538b2efec..af66071c0 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -51,9 +51,90 @@ run_posix_host() { assert_exit "$label" 0 "$rc" } +# File-path lane (0.30.0): Write / Edit / MultiEdit / NotebookEdit carry +# `file_path` (NotebookEdit `notebook_path`) instead of `command`, and before +# 0.30.0 the empty-COMMAND early exit returned before any matcher ran. +# is produced by the caller so one runner covers every tool. +run_win_payload() { + local label="$1" payload="$2" expected="$3" + shift 3 + local rc out + out=$(env OSTYPE=msys "$@" bash "$HOOK" <<<"$payload" 2>&1) + rc=$? + assert_exit "$label" "$expected" "$rc" + if ((expected == 2)); then + assert_contains "$label → message" "$out" "drive-root temp" + assert_contains "$label → fix" "$out" "%TEMP%" + fi +} + +run_posix_host_payload() { + local label="$1" payload="$2" + local rc + env OSTYPE=linux-gnu bash "$HOOK" <<<"$payload" >/dev/null 2>&1 + rc=$? + assert_exit "$label" 0 "$rc" +} + +notebook_path_json() { + MSYS_NO_PATHCONV=1 jq -n --arg fp "$1" \ + '{tool_name:"NotebookEdit",tool_input:{notebook_path:$fp,new_source:"x"}}' +} + # --- Host gate --------------------------------------------------------------- run_posix_host "Linux host: >/tmp/x allowed" 'echo x > /tmp/x' run_posix_host "Linux host: mkdir /tmp/x allowed" 'mkdir -p /tmp/x' +run_posix_host_payload "Linux host: Write /tmp/x allowed" "$(write_json '/tmp/x' 'body')" +run_posix_host_payload "Linux host: Edit /tmp/x allowed" "$(edit_json '/tmp/x' 'body')" + +# --- File-path lane: drive-root targets (blocked) ---------------------------- +# The 2026-08-30 incident payload: an empty C:\tmp\tmp.rSFIkHm5DO that no guard saw. +run_win_payload "Write C:\\tmp\\tmp.rSFIkHm5DO (blocked)" \ + "$(write_json 'C:\tmp\tmp.rSFIkHm5DO' 'x')" 2 +run_win_payload "Write /tmp/x (blocked)" "$(write_json '/tmp/x' 'x')" 2 +run_win_payload "Write /c/tmp/x MSYS (blocked)" "$(write_json '/c/tmp/x' 'x')" 2 +run_win_payload "Write C:/tmp/x (blocked)" "$(write_json 'C:/tmp/x' 'x')" 2 +run_win_payload "Write \\tmp\\x drive-root (blocked)" "$(write_json '\tmp\x' 'x')" 2 +run_win_payload "Write D:\\tmp\\x other drive (blocked)" "$(write_json 'D:\tmp\x' 'x')" 2 +run_win_payload "Edit /tmp/x (blocked)" "$(edit_json '/tmp/x' 'x')" 2 +run_win_payload "Edit C:\\tmp\\x (blocked)" "$(edit_json 'C:\tmp\x' 'x')" 2 +run_win_payload "MultiEdit /tmp/x (blocked)" "$(other_tool_json 'MultiEdit' '/tmp/x')" 2 +run_win_payload "NotebookEdit file_path /tmp/n.ipynb (blocked)" \ + "$(notebook_json '/tmp/n.ipynb' 'x')" 2 +run_win_payload "NotebookEdit notebook_path C:\\tmp\\n.ipynb (blocked)" \ + "$(notebook_path_json 'C:\tmp\n.ipynb')" 2 + +# --- File-path lane: legitimate targets (allowed) ---------------------------- +run_win_payload "Write under %TEMP% (allowed)" \ + "$(write_json 'C:\Users\dev\AppData\Local\Temp\scratch.txt' 'x')" 0 +run_win_payload "Write under /var/tmp (allowed)" "$(write_json '/var/tmp/x' 'x')" 0 +run_win_payload "Write repo docs/tmp (allowed)" "$(write_json 'D:\repo\docs\tmp\x.md' 'x')" 0 +run_win_payload "Write relative ./tmp (allowed)" "$(write_json './tmp/x' 'x')" 0 +run_win_payload "Write path component foo/tmp (allowed)" "$(write_json 'foo/tmp/x' 'x')" 0 +run_win_payload "Write /tmpdir sibling (allowed)" "$(write_json '/tmpdir/x' 'x')" 0 +run_win_payload "Write C:/tmp2 sibling (allowed)" "$(write_json 'C:/tmp2/x' 'x')" 0 +run_win_payload "Write UNC //server/tmp (allowed)" "$(write_json '\\server\tmp\x' 'x')" 0 +run_win_payload "Edit ordinary repo file (allowed)" \ + "$(edit_json 'D:\repo\plugins\guardrails\README.md' 'x')" 0 +# Body content is never scanned — only the target path decides. +run_win_payload "Write body mentioning /tmp (allowed)" \ + "$(write_json 'D:\repo\notes.md' 'do not write to /tmp/x')" 0 +# A tool with no path field at all must stay a no-op. +run_win_payload "Read tool, no path (allowed)" \ + "$(jq -n '{tool_name:"Read",tool_input:{}}')" 0 + +# --- File-path lane: fail-closed on a NUL-bearing path ----------------------- +# jq emits the escape textually, so the payload survives command substitution +# and the hook's own jq turns it back into a real NUL byte. +nul_out=$(env OSTYPE=msys bash "$HOOK" \ + <<<"$(jq -n '{tool_name:"Write",tool_input:{file_path:"C:/safe/\u0000/x"}}')" 2>&1) +assert_exit "Write with NUL in file_path fails closed" 2 "$?" +assert_contains "NUL block message" "$nul_out" "NUL byte" + +# --- File-path lane: kill switch --------------------------------------------- +run_win_payload "kill switch disables the file-path lane" \ + "$(write_json '/tmp/x' 'x')" 0 \ + CLAUDE_PLUGIN_OPTION_BLOCK_WINDOWS_DRIVE_TMP_ENABLED=false # --- Redirects to drive-root tmp (blocked) ----------------------------------- run_win "redirect >/tmp/x (blocked)" 'echo x > /tmp/x' 2 diff --git a/plugins/guardrails/hooks/hooks.json b/plugins/guardrails/hooks/hooks.json index 354cdd2b1..e16072f17 100644 --- a/plugins/guardrails/hooks/hooks.json +++ b/plugins/guardrails/hooks/hooks.json @@ -18,6 +18,17 @@ } ] }, + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-windows-drive-tmp.sh", + "timeout": 60, + "statusMessage": "Checking for Windows drive-root /tmp writes..." + } + ] + }, { "matcher": "Bash|PowerShell", "hooks": [ From 1d83ec8ec4ff486741a533e2a380ef9824c678d0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:54:31 -0400 Subject: [PATCH 2/9] fix(guardrails): stop the drive-tmp matcher blocking a tmp dir under a one-letter parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh-context verification of the file-path lane surfaced three defects in it. A FALSE POSITIVE the lane made reachable on every write. `D:\a\tmp\x` blocked: after slash-normalization the drive colon satisfied the left boundary of the MSYS `//tmp` alternative, so `d:` + `/a/tmp` read as a drive root. The identical MSYS spelling `/d/a/tmp/x` was allowed the whole time, so one sink decided two ways — contradicting the guard's own premise that the spellings are one sink. The defect predates this branch (the command lane blocked `mkdir -p D:\a\tmp\x` too) but the file-path lane made it reachable from every Write/Edit, so it is fixed here rather than inherited. The boundary now excludes `:`. No true positive is lost: a real MSYS drive root has no path component before `//tmp`. Pinned repro-first on both lanes per the hook-precision convention — the new stay-quiet cases exit 2 against the previous commit and 0 against this one, while `/c/tmp/x` and `C:\tmp\x` still exit 2 on both. THE REGISTRATION WAS UNASSERTED. Reverting the hooks.json half — the half without which the script change is inert — left all 142 assertions green. The suite now asserts the guard is registered on Bash|PowerShell and on each of Write, Edit, MultiEdit and NotebookEdit. CI-BLOCKING PORTABILITY FAILURE. Two fixture path literals this branch added carried `\s` and one carried `\b`, which the repo's shell-portability scanner reads as GNU-only regex classes; `scripts/check-shell-portability.sh` exited 1 on the test file. Resolved by renaming the fixture segments rather than suppressing the scanner — a fixture is not worth a `portability-ok` comment when a different letter says the same thing. Also from the same review, honesty corrections rather than defects in behavior: - The README's budget accounting no longer states a set-level delta. The n=12 table measured AFTER *lower* than BEFORE, and the paired A/B's per-trial ratios span 0.55x-1.82x with several trials putting AFTER faster, which is physically impossible and is host noise. The delta is below this host's noise floor and the doc now says so; what it states instead is the hook's own measured cost and the parallel-dispatch bound that follows from it. It also records that the convention's surface counts PostToolUse too, so the existing overage is deeper than the PreToolUse-only slice shows. - The README's ADR 0003 record no longer presents "100% precision" as a standalone result. Precision is 1/1; the corpus is one Windows host and one operator and contains no MultiEdit or NotebookEdit entries. Per ADR 0003 rule 3 it now names the ratio considered acceptable for this surface and justifies it from the asymmetric cost of a wrong block versus a silent miss. - The CHANGELOG claimed "a tool name matches one group". Claude Code fires every matching group; a Write now matches two guardrails groups. The conclusion holds because this hook sits in exactly one of them. - The README's "a prose mention of /tmp is never a block" was true only of the file-path lane; the Bash lane still sees a heredoc body. The telemetry subject now resolves inside emit_tel. hook::extract_bash_subject runs in a command substitution, and that fork was paid on every tool call with no sink wired, and would now be paid on every Write to obtain a constant. Closes #3501 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- plugins/guardrails/CHANGELOG.md | 22 ++++- plugins/guardrails/README.md | 95 ++++++++++++------- .../hooks/block-windows-drive-tmp.sh | 23 ++++- .../hooks/block-windows-drive-tmp.test.sh | 40 +++++++- 4 files changed, 138 insertions(+), 42 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 9a6e3fb88..895b18dda 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -27,12 +27,24 @@ All notable changes to the `guardrails` plugin are documented here. Format follo NotebookEdit` matcher string, so `secret-pattern-detection` and `hardcoded-path-check` do not silently acquire `MultiEdit`, and not a widening of `Bash|PowerShell`, which would have attached seven command-lane guards to - every file write. A tool name matches one group, so the hook still fires once - per tool call. + every file write. Claude Code fires every group whose matcher matches, so a + `Write` now matches two guardrails groups — but this hook appears in exactly + one of them, so it still fires once per tool call. - **A `file-path` telemetry form**, alongside `redirect` / `write-utility` / `too-long`. The privacy floor is unchanged: `subject` is the bare tool name on this lane (`Write`), and the target path never reaches the envelope. +- **A `tmp` directory under a single-letter parent no longer blocks.** + `D:\a\tmp\x` matched: after slash-normalization the drive colon satisfied the + left boundary of the MSYS `//tmp` alternative, so `d:` + `/a/tmp` read + as a drive root. The identical MSYS spelling `/d/a/tmp/x` was allowed the + whole time, so one sink decided two ways. The defect predates the file-path + lane — the command lane blocked `mkdir -p D:\a\tmp\x` too — but the lane made + it reachable from every write, so it is fixed here rather than inherited. The + boundary now excludes `:`; no true positive is lost, because a real MSYS drive + root has no path component before `//tmp`. Pinned repro-first on both + lanes per the hook-precision convention. + ### Changed - **Both doors feed one matcher.** The file-path lane calls the shipped @@ -50,6 +62,12 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `has_redirect_to_drive_root_tmp` runs `mask_quoted_redirect_ops` in a command substitution, and forking to scan an empty string would be per-Write budget spent to reach a foregone answer. +- **The telemetry subject resolves inside `emit_tel`.** `hook::extract_bash_subject` + runs in a command substitution, and that fork was paid on every tool call even + with no telemetry sink wired — the default — and would now be paid on every + `Write` to obtain a constant, since the helper returns the bare tool name for + any tool but Bash. Same shape as the plugin's other lazily-resolved telemetry + fields. ### Notes diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index b1a21d5dd..4b6289f85 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -242,23 +242,40 @@ out of scope until such a signal exists. sweep, per [ADR 0003](../../docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.md).** Corpus: 204 distinct `file_path` / `notebook_path` values that a real `Write`, `Edit`, `MultiEdit` or `NotebookEdit` actually carried across 202 local Claude - Code session transcripts on a Windows host — the lane's real deployment - surface, absolute Windows and MSYS paths rather than repo-relative ones a - drive-root matcher could never match. **1 finding in 204 (0.49% firing), and - it was a true positive**: `/tmp/tmp.rSFIkHm5DO/worktree-root`, the very write - that produced the `C:\tmp\tmp.rSFIkHm5DO` this lane exists to stop. Precision - 100% (1/1); the near-misses that make a zero informative — `%TEMP%` paths, - `/var/tmp`, `docs/tmp`, `./tmp`, `foo/tmp`, `/tmpdir`, `C:/tmp2`, UNC - `\\server\tmp` — are all in the corpus or the contract suite and none fired. - Six seeded spellings were detected end-to-end. Compare the ADR's shipped - reference guard, promoted at 0.51% firing and 57% precision. + Code session transcripts on a Windows host — absolute Windows and MSYS paths, + not the repo-relative ones a drive-root matcher could never match, which is + what makes a low finding count informative here. **1 finding in 204 (0.49% + firing), and it was a true positive**: `/tmp/tmp.rSFIkHm5DO/worktree-root`, the + very write that produced the `C:\tmp\tmp.rSFIkHm5DO` this lane exists to stop. + Six seeded spellings were detected end to end. + + **What that evidence does and does not support, stated plainly.** Precision is + 1/1, so the ratio is 100% and the sample is one — this is the ADR's + near-zero-findings branch, where the seeded-detection burden carries the + argument and the precision figure by itself does not. The corpus is one + Windows host and one operator, so it is evidence about this deployment and + weaker evidence about others; it contains no `MultiEdit` or `NotebookEdit` + entries at all, and those two tools are covered by the contract suite and by + the shared matcher, not by the sweep. **The ratio considered acceptable for + this surface is a false-positive rate near zero, and the justification is that + the cost of a wrong block here is unusually low** — the agent gets a stderr + line naming `%TEMP%` and reissues the write, which is a second of friction, + against a missed write that is silent by construction and was found only by + noticing litter on a volume root days later. The near-misses that would + falsify the ratio (`%TEMP%` paths, `/var/tmp`, `docs/tmp`, `./tmp`, `foo/tmp`, + `/tmpdir`, `C:/tmp2`, UNC `\\host\tmp`, and a `tmp` directory under a + single-letter parent) are pinned as MUST-stay-quiet cases in the contract + suite. Compare the ADR's shipped reference guard, promoted at 0.51% firing and + 57% precision. - **`block-windows-drive-tmp` reads the payload's PATH fields, never its content.** `.tool_input.content` / `.new_string` / `.new_source` are deliberately not requested. `HOOK_JQ_FIELDS_NUL` is computed across every requested field, so pulling written content in would make this guard fail closed on a NUL anywhere in a file body — that surface belongs to `hardcoded-path-check` and `secret-pattern-detection`. A prose mention of - `/tmp` inside a written file is therefore never a block. + `/tmp` inside a written file is therefore never a block **on this lane** — + on the Bash lane the command string is all the guard sees, so a heredoc body + carrying `C:\tmp` inside a `cat > file` still matches there, as it did before. - **`block-windows-drive-tmp` puts no length ceiling on a file path, and that is a decision.** `MAX_COMMAND_LEN` (16384) fails the command lane closed because that lane walks its string character by character twice before matching @@ -298,29 +315,39 @@ that ratio back at 80 ms. The spawn-equivalent figure is the stable one. | guardrails per-`Write` PreToolUse set BEFORE (2 hooks, concurrent) | 12.59 | ≈ 1,007 ms | | guardrails per-`Write` PreToolUse set AFTER (3 hooks, concurrent) | 12.34 | ≈ 987 ms | -A separate **paired A/B** (n=15, BEFORE and AFTER launched back to back inside -each trial in alternating order, so load drift biases both arms equally) put the -set wall at a mean **1.26×** after the addition, with per-trial ratios spanning -0.55×–1.82×. Read the two together honestly: **the added hook costs ≈ 0.5 s of -reference-host work of its own, and because the harness dispatches matching -hooks in parallel the set wall it joins grows by somewhere between nothing -measurable and ≈ 26%** — the wall is set by the slowest member, and on an -unloaded host this hook is not that member. The spread is the host's, not the -hook's, and a re-measurement on a quiet reference box would narrow it. - -**Share of the budget, and the overage.** The convention's per-tool-call ceiling -is ≤ 1 s typical / ≤ 2 s worst-case, and the fleet's binding per-`Write` -accounting there is **≈ 1.9 s** for the whole always-on set (two formatters plus -three guardrails verifiers) — already over the typical ceiling before this -change. The guardrails PreToolUse slice of that measured ≈ 1.0 s reference-host -here; this widening takes it to ≈ 1.0–1.3 s, so the increment is **≈ 0–0.3 s, -roughly 0–15% of the ≤ 2 s worst-case ceiling**, landing on a surface that is -already in overage. Per the convention's rule 2 the budget does not relax to -absorb that: the overage is guardrails' own spawn-reduction work (#1403), and -this change pays part of its way by removing the `printf | tr` fork+exec pair -from the shared normalizer — a cost the pre-existing per-Bash-call lane was -paying on every call. Operators who cannot afford the increment have the -per-hook kill switch below. +**The hook's own cost is the measurement that holds: ≈ 6.3 spawn-equivalents, +≈ 505 ms of reference-host work per `Write`.** The set rows are reported for +completeness and must not be read as a delta, because they do not resolve one — +`AFTER` measures *lower* than `BEFORE`, and adding a hook cannot make a set +faster. A separate **paired A/B** (n=15, BEFORE and AFTER launched back to back +inside each trial in alternating order so load drift biases both arms equally) +came out at a mean **1.26×**, but its per-trial ratios span **0.55×–1.82×** — +several trials put AFTER *faster* than BEFORE, which is physically impossible +and is the host's noise, not the hook's cost. **On this host the set-level delta +is below the noise floor and this accounting does not state one.** What can be +said: the harness dispatches matching hooks in parallel, so the set wall is the +max of its members rather than their sum, and a member costing ≈ 505 ms joining +a set already walling at ≈ 1 s cannot raise that wall by more than its own cost +and will usually raise it by less. A re-measurement on a quiet reference host is +the way to replace this bound with a number, and is the honest follow-up. + +**Share of the budget, and the overage.** The convention's ceiling is +≤ 1 s typical / ≤ 2 s worst-case **per tool call, counting `PreToolUse` and +`PostToolUse` together for one matcher** — so the surface this widening lands on +is larger than the table above measures: guardrails also runs three `PostToolUse` +verifiers on `Write|Edit`, and the fleet's binding accounting for the whole +per-`Write` set is **≈ 1.9 s** (two formatters plus three guardrails verifiers), +already over the typical ceiling before this change and deeper into overage than +the PreToolUse-only slice measured here suggests. Against that surface the +guard's own **≈ 505 ms is ≈ 25% of the ≤ 2 s worst-case ceiling as an upper +bound on its contribution**, and less than that in practice because it is +dispatched in parallel rather than added. Per the convention's rule 2 the budget +does not relax to absorb the overage: remediation is guardrails' own +spawn-reduction work (#1403), and this change pays part of its way — it removes +the `printf | tr` fork-and-exec pair from the shared normalizer and stops +resolving the telemetry subject in a subshell when no sink is wired, both costs +the pre-existing per-Bash-call lane was paying on every call. Operators who +cannot afford the addition have the per-hook kill switch below. ## Per-hook kill switches diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index a6ddae92d..e0e659ab5 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -113,12 +113,18 @@ msys* | cygwin* | win32) ;; esac MAX_COMMAND_LEN=16384 -SUBJECT=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 - local data + # Resolved HERE, not at top level: hook::extract_bash_subject runs in a + # command substitution, and that fork was being paid on every tool call even + # when no telemetry sink is wired — which is the default, and now on the + # per-Write surface too, where the helper returns the bare tool name and the + # fork buys a constant. Same shape as the plugin's other lazily-resolved + # telemetry fields. + local SUBJECT data + SUBJECT=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") data=$(jq -n --arg tool "$TOOL_NAME" --arg subject "$SUBJECT" --arg form "$2" \ '{tool:$tool,subject:$subject,form:$form}' 2>/dev/null) || data='{"tool":"Bash","subject":"","form":""}' hook::emit_telemetry "block-windows-drive-tmp" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" @@ -180,8 +186,17 @@ has_drive_root_tmp() { if [[ "$s" =~ (^|[^[:alnum:]._/])\/tmp(\/|[^[:alnum:]_./-]|$) ]]; then return 0 fi - # MSYS //tmp - if [[ "$s" =~ (^|[^[:alnum:]._/])\/[a-z]\/tmp(\/|[^[:alnum:]_./-]|$) ]]; then + # MSYS //tmp. The left boundary excludes `:` as well, because after + # slash-normalization a drive colon otherwise satisfies it and `D:\a\tmp\x` + # — an ordinary `tmp` directory two levels down, not a drive root — reads as + # `d:` + `/a/tmp`. That FALSE POSITIVE predates the file-path lane (the + # command lane blocked `mkdir -p D:\a\tmp\x` too), but the lane makes it + # reachable from every Write/Edit, so it is fixed here rather than inherited. + # It also made the guard contradict its own premise: `/d/a/tmp/x` is the same + # path in MSYS spelling and was correctly allowed, so one sink decided two + # ways. No true positive is lost — a real MSYS drive root has no path + # component before `//tmp`. + if [[ "$s" =~ (^|[^[:alnum:]._/:])\/[a-z]\/tmp(\/|[^[:alnum:]_./-]|$) ]]; then return 0 fi # Drive-letter X:/tmp diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index af66071c0..1b5caa489 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -105,15 +105,19 @@ run_win_payload "NotebookEdit notebook_path C:\\tmp\\n.ipynb (blocked)" \ "$(notebook_path_json 'C:\tmp\n.ipynb')" 2 # --- File-path lane: legitimate targets (allowed) ---------------------------- +# Fixture path segments deliberately avoid a literal `\s`: the repo's +# shell-portability scanner reads `\\s` as a GNU-only regex class wherever it +# appears, and a fixture is not worth a suppression comment when a different +# letter says exactly the same thing. run_win_payload "Write under %TEMP% (allowed)" \ - "$(write_json 'C:\Users\dev\AppData\Local\Temp\scratch.txt' 'x')" 0 + "$(write_json 'C:\Users\dev\AppData\Local\Temp\note.txt' 'x')" 0 run_win_payload "Write under /var/tmp (allowed)" "$(write_json '/var/tmp/x' 'x')" 0 run_win_payload "Write repo docs/tmp (allowed)" "$(write_json 'D:\repo\docs\tmp\x.md' 'x')" 0 run_win_payload "Write relative ./tmp (allowed)" "$(write_json './tmp/x' 'x')" 0 run_win_payload "Write path component foo/tmp (allowed)" "$(write_json 'foo/tmp/x' 'x')" 0 run_win_payload "Write /tmpdir sibling (allowed)" "$(write_json '/tmpdir/x' 'x')" 0 run_win_payload "Write C:/tmp2 sibling (allowed)" "$(write_json 'C:/tmp2/x' 'x')" 0 -run_win_payload "Write UNC //server/tmp (allowed)" "$(write_json '\\server\tmp\x' 'x')" 0 +run_win_payload "Write UNC //host/tmp (allowed)" "$(write_json '\\host\tmp\x' 'x')" 0 run_win_payload "Edit ordinary repo file (allowed)" \ "$(edit_json 'D:\repo\plugins\guardrails\README.md' 'x')" 0 # Body content is never scanned — only the target path decides. @@ -123,6 +127,38 @@ run_win_payload "Write body mentioning /tmp (allowed)" \ run_win_payload "Read tool, no path (allowed)" \ "$(jq -n '{tool_name:"Read",tool_input:{}}')" 0 +# --- A `tmp` directory under a single-letter parent (allowed) ---------------- +# MUST-STAY-QUIET, added repro-first: before the left-boundary fix these all +# blocked, because after slash-normalization the drive colon satisfied the MSYS +# alternative's left boundary and `D:\a\tmp\x` read as `d:` + `/a/tmp`. The +# identical MSYS spelling `/d/a/tmp/x` was allowed the whole time, so one sink +# decided two ways. Both lanes are pinned: the file-path lane is where the +# defect became reachable on every write, the Bash lane is where it already was. +run_win_payload "Write D:\\a\\tmp\\x subdir tmp (allowed)" "$(write_json 'D:\a\tmp\x' 'x')" 0 +run_win_payload "Write C:\\q\\tmp\\out.log subdir tmp (allowed)" \ + "$(write_json 'C:\q\tmp\out.log' 'x')" 0 +run_win_payload "Write /d/a/tmp/x MSYS spelling (allowed)" "$(write_json '/d/a/tmp/x' 'x')" 0 +run_win "mkdir D:\\a\\tmp\\x subdir tmp (allowed)" 'mkdir -p D:\a\tmp\x' 0 +run_win "mkdir /d/a/tmp/x MSYS spelling (allowed)" 'mkdir -p /d/a/tmp/x' 0 +# The genuine MSYS drive root must still block, with and without a leading word. +run_win "mkdir /c/tmp/x drive root (still blocked)" 'mkdir -p /c/tmp/x' 2 +run_win_payload "Write /c/tmp/x drive root (still blocked)" "$(write_json '/c/tmp/x' 'x')" 2 + +# --- Registration liveness --------------------------------------------------- +# The script half of this guard is inert without the matcher registration: a +# Write payload only reaches the hook because hooks.json routes it here. Reverting +# that registration alone would leave every assertion above green, so assert it. +HOOKS_JSON="$HOOK_DIR/hooks.json" +reg=$(jq -r --arg h "block-windows-drive-tmp.sh" ' + .hooks.PreToolUse[] + | select([.hooks[].command] | any(contains($h))) + | .matcher' "$HOOKS_JSON" 2>/dev/null | tr '\n' ' ') +assert_contains "hooks.json registers the guard on the command tools" "$reg" "Bash|PowerShell" +assert_contains "hooks.json registers the guard on Write" "$reg" "Write" +assert_contains "hooks.json registers the guard on Edit" "$reg" "Edit" +assert_contains "hooks.json registers the guard on MultiEdit" "$reg" "MultiEdit" +assert_contains "hooks.json registers the guard on NotebookEdit" "$reg" "NotebookEdit" + # --- File-path lane: fail-closed on a NUL-bearing path ----------------------- # jq emits the escape textually, so the payload survives command substitution # and the hook's own jq turns it back into a real NUL byte. From e3954e75a4ed2e3b9edfc2fec88ff26be54c7b22 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:48:02 -0400 Subject: [PATCH 3/9] fix(guardrails): keep a PowerShell parameter colon matching after the drive-colon fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-pass fresh-context verification of the previous commit found that excluding the colon from the MSYS drive-form left boundary — the fix for the `D:\a\tmp\x` false positive — took a real class out with it. Six colon-bound PowerShell write spellings regressed from blocked to allowed: Set-Content, New-Item and Add-Content with `-Path:`, Out-File with `-FilePath:`, and Copy-Item and Move-Item with `-Destination:`, each targeting the MSYS drive root. `-Parameter:Value` is valid PowerShell binding, and the space-bound twin of one of those is a pinned MUST-fire case — so the previous commit reproduced, on the PowerShell lane, the very "one sink decided two ways" inconsistency it claimed to remove. The earlier justification reasoned about path shapes, but this matcher also runs over whole command strings, where a colon sits next to a path for parameter-binding reasons that have nothing to do with a drive letter. The MSYS alternative is now two arms discriminating on what precedes the colon. A DRIVE SPEC is exactly one alphanumeric at a word boundary and no longer satisfies the boundary; every other colon still does — a non-alphanumeric immediately before it, a two-alphanumeric token such as `-Path:` or `host:`, or a single alphanumeric behind a flag dash. Swept rather than argued, since hand-picked cases plus a plausibility argument is what produced the regression. Both versions' has_drive_root_tmp() were lifted from their own files by sed and compared over 1,702 probes: every ASCII printable as the immediate left neighbour, every two-character context ending in a colon, nine drive letters in eight surrounding contexts, deeper subdirectory shapes, and the colon-bearing forms a sweep alone does not reach. All 266 changed verdicts are the drive-spec reading and the arithmetic closes: 192 non-alnum-then-alnum colon contexts, 72 explicit drive-letter subdirectory probes, the leading bare colon, and `D:\a\tmp` itself. Every real drive-root spelling still matches on both versions. Two accepted residuals, both unchanged from the shipped guard rather than introduced here: a PATH-style search list presents the token shape and still matches, and a remote spec with a single-letter host now reads as a drive spec and does not — it names a path on another machine, which this guard never governed. Also from the second round: - The `file-path` telemetry envelope was documented in the schema and executed by nothing; the existing telemetry case pipes a Bash payload. A file-path case now asserts the Write / Write / file-path envelope and that the target path never reaches it. - The registration assertions were partly vacuous. `Edit` could never fail while `MultiEdit` passed, and a matcher with the pipes removed routes nothing while satisfying every containment check. They now split the matcher on the pipe and compare the exact sorted alternative set, and check that the registered command names a file that exists. - `guardrails-test-helpers.sh`'s `command_json` omits MSYS_NO_PATHCONV, so on a Windows host Git Bash rewrites an MSYS drive path to the drive-letter form before jq sees it and every MSYS assertion silently exercises the wrong alternative. The MSYS cases now build payloads through local no-pathconv builders; the shared helper is duplicated across plugins under a source-drift gate and is not edited from here. - A README sentence added by the previous commit promised that a heredoc body carrying a drive-root path blocks on the Bash lane. It does not, on either version — segment_writes_drive_root_tmp requires a creator verb and `cat` is not one. Corrected. The ADR 0003 sweep is re-run against the FINAL matcher, not an earlier draft: 259 distinct real Write/Edit/MultiEdit/NotebookEdit target paths across 227 local transcripts, 1 finding (0.39%) and it is the incident write itself, 6/6 seeded spellings detected end to end. README updated with those figures and dated, since the corpus grows as the host accumulates sessions. Contract suite green at PASS=181 FAIL=0 against this tree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- plugins/guardrails/CHANGELOG.md | 55 ++++++++--- plugins/guardrails/README.md | 17 ++-- .../hooks/block-windows-drive-tmp.sh | 45 +++++++-- .../hooks/block-windows-drive-tmp.test.sh | 97 ++++++++++++++++--- 4 files changed, 174 insertions(+), 40 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 895b18dda..e2eceda86 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -17,6 +17,32 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `.tool_input.notebook_path`, and the early exit tests both doors. Verified repro-first: the four seeded spellings (`C:\tmp\…`, `/tmp/…`, `/c/tmp/…`, `C:/tmp/…`) exit 0 against the unmodified hook and 2 against this one. +- **A `tmp` directory under a single-letter parent no longer blocks.** + `D:\a\tmp\x` matched: after slash-normalization the drive colon satisfied the + left boundary of the MSYS `//tmp` alternative, so `d:` + `/a/tmp` read + as a drive root. The identical MSYS spelling `/d/a/tmp/x` was allowed the + whole time, so one sink decided two ways. The defect predates the file-path + lane — the command lane blocked `mkdir -p D:\a\tmp\x` too — but the lane made + it reachable from every write, so it is fixed here rather than inherited. + The MSYS alternative is now two arms, because a `:` on the left is ambiguous + and the two readings decide oppositely. A DRIVE SPEC — exactly one + alphanumeric at a word boundary — no longer satisfies the boundary; every + other colon still does, including a PowerShell PARAMETER colon, so + `Set-Content -Path:/c/tmp/x` keeps blocking exactly as its space-bound twin + does. Excluding `:` outright, which a first attempt did, would have dropped + that whole class. The narrowing was then swept exhaustively against the + shipped matcher — every ASCII printable as the immediate left neighbour, + every two-character context ending in a colon, nine drive letters in eight + surrounding contexts, and the colon-bearing shapes a sweep alone does not + reach, 1,702 probes — and **all 266 changed verdicts are the drive-spec + reading**: 192 `:` contexts, 72 explicit `X:\a\tmp` probes, + the leading bare `:`, and `D:\a\tmp` itself. Every real drive-root spelling + still matches. Two accepted residuals, both unchanged from the shipped guard + rather than introduced: a PATH-style list (`PATH=/usr/bin:/c/tmp cmd`) + presents the multi-character token shape and still matches, and a remote spec + with a single-letter host (`ssh u@h:/c/tmp/x`) now reads as a drive spec and + does not — it names a path on another machine, which this guard never + governed. Pinned repro-first on both lanes. ### Added @@ -34,17 +60,6 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `too-long`. The privacy floor is unchanged: `subject` is the bare tool name on this lane (`Write`), and the target path never reaches the envelope. -- **A `tmp` directory under a single-letter parent no longer blocks.** - `D:\a\tmp\x` matched: after slash-normalization the drive colon satisfied the - left boundary of the MSYS `//tmp` alternative, so `d:` + `/a/tmp` read - as a drive root. The identical MSYS spelling `/d/a/tmp/x` was allowed the - whole time, so one sink decided two ways. The defect predates the file-path - lane — the command lane blocked `mkdir -p D:\a\tmp\x` too — but the lane made - it reachable from every write, so it is fixed here rather than inherited. The - boundary now excludes `:`; no true positive is lost, because a real MSYS drive - root has no path component before `//tmp`. Pinned repro-first on both - lanes per the hook-precision convention. - ### Changed - **Both doors feed one matcher.** The file-path lane calls the shipped @@ -85,7 +100,23 @@ All notable changes to the `guardrails` plugin are documented here. Format follo does not degrade with length, and a blocking ceiling would only add a false-positive class. The payload stays bounded by `hook::buffer_stdin`. - **Measured budget share** for the widened surface is recorded in the README's - hook-budget accounting. + hook-budget accounting, alongside the ADR 0003 sweep for the new lane. +- **Two test-fidelity gaps closed alongside the lane.** The `hooks.json` + registration — the half of this change without which the script edit is inert + — is now asserted, by splitting the matcher on `|` and comparing the exact + alternative set rather than substring-searching it (a containment test for + `Edit` can never fail while `MultiEdit` passes, and a matcher with the pipes + removed routes nothing while satisfying every containment check). And the MSYS + `//tmp` cases build their payloads through local no-pathconv builders: + `guardrails-test-helpers.sh`'s `command_json` omits `MSYS_NO_PATHCONV`, so on + a Windows host Git Bash rewrites `/c/tmp/x` to `C:/tmp/x` before jq sees it and + the assertion silently exercises the drive-letter alternative instead. The + shared helper is duplicated across plugins under a source-drift gate and is + not edited from here. +- **The `file-path` telemetry form is now executed, not just documented.** The + existing telemetry case pipes a Bash payload; a file-path case asserts the + `Write` / `Write` / `file-path` envelope and that the target path never + reaches it. ## [0.29.22] diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 4b6289f85..7fa157f1b 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -240,11 +240,11 @@ out of scope until such a signal exists. tool, or a Bash form the command lane's own residuals already allow. - **`block-windows-drive-tmp`'s file-path lane shipped blocking on a measured sweep, per [ADR 0003](../../docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.md).** - Corpus: 204 distinct `file_path` / `notebook_path` values that a real `Write`, - `Edit`, `MultiEdit` or `NotebookEdit` actually carried across 202 local Claude + Corpus: 259 distinct `file_path` / `notebook_path` values that a real `Write`, + `Edit`, `MultiEdit` or `NotebookEdit` actually carried across 227 local Claude Code session transcripts on a Windows host — absolute Windows and MSYS paths, not the repo-relative ones a drive-root matcher could never match, which is - what makes a low finding count informative here. **1 finding in 204 (0.49% + what makes a low finding count informative here. **1 finding in 259 (0.39% firing), and it was a true positive**: `/tmp/tmp.rSFIkHm5DO/worktree-root`, the very write that produced the `C:\tmp\tmp.rSFIkHm5DO` this lane exists to stop. Six seeded spellings were detected end to end. @@ -266,16 +266,19 @@ out of scope until such a signal exists. `/tmpdir`, `C:/tmp2`, UNC `\\host\tmp`, and a `tmp` directory under a single-letter parent) are pinned as MUST-stay-quiet cases in the contract suite. Compare the ADR's shipped reference guard, promoted at 0.51% firing and - 57% precision. + 57% precision. Corpus counts are as of 2026-08-31 on the measuring host and + grow as that host accumulates sessions; the figure that matters is the ratio. - **`block-windows-drive-tmp` reads the payload's PATH fields, never its content.** `.tool_input.content` / `.new_string` / `.new_source` are deliberately not requested. `HOOK_JQ_FIELDS_NUL` is computed across every requested field, so pulling written content in would make this guard fail closed on a NUL anywhere in a file body — that surface belongs to `hardcoded-path-check` and `secret-pattern-detection`. A prose mention of - `/tmp` inside a written file is therefore never a block **on this lane** — - on the Bash lane the command string is all the guard sees, so a heredoc body - carrying `C:\tmp` inside a `cat > file` still matches there, as it did before. + `/tmp` inside a written file is therefore never a block on this lane. The + Bash lane is scoped differently but reaches the same place: it sees only the + command string, and a drive-root path there still has to sit in a + write-shaped position, so a heredoc body carrying `C:\tmp` inside a + `cat > file` does not block either. - **`block-windows-drive-tmp` puts no length ceiling on a file path, and that is a decision.** `MAX_COMMAND_LEN` (16384) fails the command lane closed because that lane walks its string character by character twice before matching diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index e0e659ab5..11413367a 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -186,19 +186,44 @@ has_drive_root_tmp() { if [[ "$s" =~ (^|[^[:alnum:]._/])\/tmp(\/|[^[:alnum:]_./-]|$) ]]; then return 0 fi - # MSYS //tmp. The left boundary excludes `:` as well, because after - # slash-normalization a drive colon otherwise satisfies it and `D:\a\tmp\x` - # — an ordinary `tmp` directory two levels down, not a drive root — reads as - # `d:` + `/a/tmp`. That FALSE POSITIVE predates the file-path lane (the - # command lane blocked `mkdir -p D:\a\tmp\x` too), but the lane makes it - # reachable from every Write/Edit, so it is fixed here rather than inherited. - # It also made the guard contradict its own premise: `/d/a/tmp/x` is the same - # path in MSYS spelling and was correctly allowed, so one sink decided two - # ways. No true positive is lost — a real MSYS drive root has no path - # component before `//tmp`. + # MSYS //tmp, in two arms because a `:` on the left is ambiguous and + # the two readings decide oppositely. + # + # A DRIVE COLON must NOT satisfy the boundary: after slash-normalization + # `D:\a\tmp\x` reads as `d:` + `/a/tmp`, and that is an ordinary `tmp` + # directory two levels down, not a drive root. Blocking it was a FALSE + # POSITIVE that predates the file-path lane (the command lane blocked + # `mkdir -p D:\a\tmp\x` too), and it made the guard contradict its own + # premise, since the identical MSYS spelling `/d/a/tmp/x` was allowed — one + # sink deciding two ways. The lane makes it reachable from every Write/Edit, + # so it is fixed here rather than inherited. + # + # A PARAMETER COLON must still satisfy it. `-Path:` / `-FilePath:` / + # `-Destination:` is valid PowerShell binding, so `Set-Content -Path:/c/tmp/x` + # is a real drive-root write and one of its space-bound twins is a pinned + # MUST-fire case. Excluding `:` outright would have dropped that whole class. + # + # The discriminator is what sits before the colon. A DRIVE SPEC is exactly one + # alphanumeric at a word boundary — `D:`, ` D:`, `"D:`, `(D:` — so arm 2 + # excludes only that shape and takes every other colon, which is the narrowest + # change that fixes the false positive. Its three alternatives are: a + # non-alphanumeric immediately before the colon (`;:`, `":`, `):` — never a + # drive spec); two alphanumerics (a multi-character token such as `-Path:` or + # `host:`); and a single alphanumeric behind a flag dash (`-t:`). + # + # Two accepted residuals, both unchanged from before the fix rather than + # introduced by it. A PATH-style list (`PATH=/usr/bin:/c/tmp cmd`) presents + # the multi-character-token shape and still matches, so a command whose write + # target is elsewhere can be matched over a search-path entry — lexically + # indistinguishable from a bound parameter. And a remote spec with a + # single-letter host (`ssh u@h:/c/tmp/x`) now reads as a drive spec and is not + # matched; it names a path on another machine, which this guard never governed. if [[ "$s" =~ (^|[^[:alnum:]._/:])\/[a-z]\/tmp(\/|[^[:alnum:]_./-]|$) ]]; then return 0 fi + if [[ "$s" =~ ([^[:alnum:]]|[[:alnum:]][[:alnum:]]|-[[:alnum:]]):\/[a-z]\/tmp(\/|[^[:alnum:]_./-]|$) ]]; then + return 0 + fi # Drive-letter X:/tmp if [[ "$s" =~ (^|[^[:alnum:]])[a-z]:\/tmp(\/|[^[:alnum:]_./-]|$) ]]; then return 0 diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index 1b5caa489..ef936f798 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -81,6 +81,21 @@ notebook_path_json() { '{tool_name:"NotebookEdit",tool_input:{notebook_path:$fp,new_source:"x"}}' } +# Command payload builders that PRESERVE an MSYS `//tmp` spelling. +# guardrails-test-helpers.sh's command_json / pwsh_command_json omit +# MSYS_NO_PATHCONV, so on a Windows host Git Bash rewrites `/c/tmp/x` into +# `C:/tmp/x` before jq ever sees it — the payload then exercises the +# DRIVE-LETTER alternative instead of the MSYS one, and any assertion aimed at +# the MSYS matcher is vacuous exactly where it matters most. The shared helper +# is duplicated across plugins under a source-drift gate, so it is not edited +# from here; these local builders keep the MSYS cases honest on both platforms. +msys_command_json() { + MSYS_NO_PATHCONV=1 jq -n --arg cmd "$1" '{tool_name:"Bash",tool_input:{command:$cmd}}' +} +msys_pwsh_command_json() { + MSYS_NO_PATHCONV=1 jq -n --arg cmd "$1" '{tool_name:"PowerShell",tool_input:{command:$cmd}}' +} + # --- Host gate --------------------------------------------------------------- run_posix_host "Linux host: >/tmp/x allowed" 'echo x > /tmp/x' run_posix_host "Linux host: mkdir /tmp/x allowed" 'mkdir -p /tmp/x' @@ -139,25 +154,59 @@ run_win_payload "Write C:\\q\\tmp\\out.log subdir tmp (allowed)" \ "$(write_json 'C:\q\tmp\out.log' 'x')" 0 run_win_payload "Write /d/a/tmp/x MSYS spelling (allowed)" "$(write_json '/d/a/tmp/x' 'x')" 0 run_win "mkdir D:\\a\\tmp\\x subdir tmp (allowed)" 'mkdir -p D:\a\tmp\x' 0 -run_win "mkdir /d/a/tmp/x MSYS spelling (allowed)" 'mkdir -p /d/a/tmp/x' 0 -# The genuine MSYS drive root must still block, with and without a leading word. -run_win "mkdir /c/tmp/x drive root (still blocked)" 'mkdir -p /c/tmp/x' 2 +# MSYS spellings go through the local no-pathconv builders, or Git Bash rewrites +# them to the drive-letter form and the assertion stops testing this matcher. +run_win_payload "mkdir /d/a/tmp/x MSYS spelling (allowed)" \ + "$(msys_command_json 'mkdir -p /d/a/tmp/x')" 0 +# The genuine MSYS drive root must still block. +run_win_payload "mkdir /c/tmp/x drive root (still blocked)" \ + "$(msys_command_json 'mkdir -p /c/tmp/x')" 2 run_win_payload "Write /c/tmp/x drive root (still blocked)" "$(write_json '/c/tmp/x' 'x')" 2 +# A PowerShell parameter colon is NOT a drive colon. `-Path:/c/tmp/x` binds the +# same value as `-Path /c/tmp/x`, so both must block; the boundary fix above +# must not take this class out with the drive-colon false positive. +run_win_payload "PS: Set-Content -Path:/c/tmp/x colon-bound (blocked)" \ + "$(msys_pwsh_command_json 'Set-Content -Path:/c/tmp/x -Value hi')" 2 +run_win_payload "PS: New-Item -Path:/c/tmp/x colon-bound (blocked)" \ + "$(msys_pwsh_command_json 'New-Item -Path:/c/tmp/x -ItemType File')" 2 +run_win_payload "PS: Out-File -FilePath:/c/tmp/x colon-bound (blocked)" \ + "$(msys_pwsh_command_json "'hi' | Out-File -FilePath:/c/tmp/x")" 2 +run_win_payload "PS: Add-Content -Path:/c/tmp/x colon-bound (blocked)" \ + "$(msys_pwsh_command_json 'Add-Content -Path:/c/tmp/x -Value hi')" 2 +run_win_payload "PS: Copy-Item -Destination:/c/tmp/a colon-bound (blocked)" \ + "$(msys_pwsh_command_json 'Copy-Item .\a -Destination:/c/tmp/a')" 2 +run_win_payload "PS: Move-Item -Destination:/c/tmp/a colon-bound (blocked)" \ + "$(msys_pwsh_command_json 'Move-Item .\a -Destination:/c/tmp/a')" 2 +# ... and the drive-colon reading of the same character still must not fire. +run_win_payload "PS: Set-Content -Path:D:/a/tmp/x subdir tmp (allowed)" \ + "$(msys_pwsh_command_json 'Set-Content -Path:D:/a/tmp/x -Value hi')" 0 + # --- Registration liveness --------------------------------------------------- # The script half of this guard is inert without the matcher registration: a # Write payload only reaches the hook because hooks.json routes it here. Reverting # that registration alone would leave every assertion above green, so assert it. +# Matchers are split on `|` into EXACT alternatives, not substring-searched: a +# containment test for "Edit" is satisfied by "MultiEdit" and so can never fail +# on its own, and a matcher with the pipes removed ("WriteEditNotebookEdit") +# routes nothing while passing every containment check. Sorting also makes the +# assertions immune to a harmless reordering of the alternatives. HOOKS_JSON="$HOOK_DIR/hooks.json" reg=$(jq -r --arg h "block-windows-drive-tmp.sh" ' - .hooks.PreToolUse[] - | select([.hooks[].command] | any(contains($h))) - | .matcher' "$HOOKS_JSON" 2>/dev/null | tr '\n' ' ') -assert_contains "hooks.json registers the guard on the command tools" "$reg" "Bash|PowerShell" -assert_contains "hooks.json registers the guard on Write" "$reg" "Write" -assert_contains "hooks.json registers the guard on Edit" "$reg" "Edit" -assert_contains "hooks.json registers the guard on MultiEdit" "$reg" "MultiEdit" -assert_contains "hooks.json registers the guard on NotebookEdit" "$reg" "NotebookEdit" + [ .hooks.PreToolUse[] + | select([.hooks[].command] | any(contains($h))) + | .matcher | split("|")[] ] + | sort | join(" ")' "$HOOKS_JSON" 2>/dev/null) +assert_eq "hooks.json routes the guard to exactly the intended tools" \ + "Bash Edit MultiEdit NotebookEdit PowerShell Write" "$reg" +# The registration must also NAME A FILE THAT EXISTS — a command path typo +# registers cleanly and then fails to run on every tool call. +reg_cmd=$(jq -r --arg h "block-windows-drive-tmp.sh" ' + [ .hooks.PreToolUse[].hooks[].command | select(contains($h)) ] | first // ""' \ + "$HOOKS_JSON" 2>/dev/null) +reg_rel="${reg_cmd##*\"/}" +assert_eq "the registered command resolves to a file on disk" "yes" \ + "$([[ -n "$reg_rel" && -f "$HOOK_DIR/../$reg_rel" ]] && echo yes || echo no)" # --- File-path lane: fail-closed on a NUL-bearing path ----------------------- # jq emits the escape textually, so the payload survives command substitution @@ -259,4 +308,30 @@ else fi assert_contains "blocked stderr still present with sink" "$out" "drive-root temp" +# --- Telemetry on the file-path lane ----------------------------------------- +# The `file-path` form and the Write-shaped `tool` / `subject` are documented in +# docs/conventions/hook-telemetry/data/block-windows-drive-tmp.schema.json, and +# the case above exercises only the Bash lane — so without this the new envelope +# is documented and never executed, and a fault in emit_tel's now-locally-scoped +# SUBJECT would leave every assertion green. `subject` must be the bare tool +# name: hook::extract_bash_subject does not tokenize a non-Bash tool, and the +# target path must never reach the envelope. +TEL_FP="$(mktemp "$TEST_TMPDIR/tmp.XXXXXXXXXX")" +SINK_FP=$(make_sink "cat > \"$TEL_FP\"") +out=$(env OSTYPE=msys HOOK_TELEMETRY_SINK="$SINK_FP" bash "$HOOK" \ + <<<"$(write_json 'C:\tmp\tmp.rSFIkHm5DO' 'x')" 2>&1) || true +wait_for_sink "$TEL_FP" || true +if [[ -s "$TEL_FP" ]]; then + tel_fp_body=$(cat "$TEL_FP") + assert_contains "file-path telemetry hook id" "$tel_fp_body" '"hook": "block-windows-drive-tmp"' + assert_contains "file-path telemetry blocked" "$tel_fp_body" '"status": "blocked"' + assert_contains "file-path telemetry form" "$tel_fp_body" '"form": "file-path"' + assert_contains "file-path telemetry tool" "$tel_fp_body" '"tool": "Write"' + assert_contains "file-path telemetry subject" "$tel_fp_body" '"subject": "Write"' + assert_absent "file-path telemetry carries no path" "$tel_fp_body" "rSFIkHm5DO" +else + ok "file-path telemetry sink empty (best-effort; block path already covered)" +fi +assert_contains "file-path blocked stderr present with sink" "$out" "drive-root temp" + report From a1887921d8bea537c4f4fee8cbdb8078fc9b540f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:38:25 -0400 Subject: [PATCH 4/9] fix(guardrails): satisfy the options-docs and machine-paths gates on the widened guard Two CI gates this branch tripped, both real and both fixed at the source rather than suppressed. plugin-options-docs-gate. The guardrails README's options table is GENERATED from plugin.json's userConfig, and I had hand-edited the block_windows_drive_tmp_enabled row to describe both lanes without updating the declaration it is generated from. The declaration now carries the two-lane wording; sync-plugin-options-docs.py --check is clean and regenerating produces no README change, so the two agree at the source. hygiene / machine-specific-paths. A fixture path in the contract test named a concrete Windows user directory. It now uses the placeholder the gate's own message prescribes. That placeholder then tripped a THIRD gate: check-shell-portability reads `\<` as a GNU-only word boundary, and a backslash separator put one immediately before the placeholder. The fixture uses forward slashes instead, a valid Windows spelling the matcher slash-normalizes anyway, and the backslash form stays covered by the D:\repo\docs\tmp and D:\a\tmp cases beside it. None of this changes what the case tests: the matcher decides on the presence of a drive-root tmp component and this path has none. The three constraints are recorded in a comment so the next author does not rediscover them one CI round at a time. Contract suite PASS=181 FAIL=0, with the %TEMP% case still exiting 0. check-shell-portability, shellcheck -x and sync-plugin-options-docs --check all clean locally. The machine-paths gate is a ci-workflows composite action with no local entry point, so that one is verified on CI. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- .../hooks/block-windows-drive-tmp.test.sh | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index eee79c519..27c08d070 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -52,7 +52,7 @@ "block_windows_drive_tmp_enabled": { "type": "boolean", "title": "block-windows-drive-tmp guard", - "description": "Block Bash/PowerShell writes whose target is a Windows drive-root temp path (/tmp, C:\\tmp, \\tmp, /c/tmp) that resolves to :\\tmp instead of %TEMP%", + "description": "Block writes whose target is a Windows drive-root temp path (/tmp, C:\\tmp, \\tmp, /c/tmp) that resolves to :\\tmp instead of %TEMP% — both Bash/PowerShell commands and Write/Edit/MultiEdit/NotebookEdit file paths. One switch covers both lanes", "default": true }, "block_exported_msys_pathconv_enabled": { diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index ef936f798..54f62b4d4 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -120,12 +120,18 @@ run_win_payload "NotebookEdit notebook_path C:\\tmp\\n.ipynb (blocked)" \ "$(notebook_path_json 'C:\tmp\n.ipynb')" 2 # --- File-path lane: legitimate targets (allowed) ---------------------------- -# Fixture path segments deliberately avoid a literal `\s`: the repo's -# shell-portability scanner reads `\\s` as a GNU-only regex class wherever it -# appears, and a fixture is not worth a suppression comment when a different -# letter says exactly the same thing. +# Three repo-wide CI gates constrain the literals below, and all three are +# satisfiable without weakening any fixture. The shell-portability scanner reads +# `\s`, `\b` and `\<` as GNU-only regex constructs wherever they appear, so no +# segment starts with those letters and no backslash precedes the placeholder; +# the machine-specific-paths gate rejects a concrete Windows user directory, so +# the user segment is the `` placeholder it prescribes. Hence the forward +# slashes here — a valid Windows spelling that the matcher slash-normalizes +# anyway, and the backslash form stays covered by the `D:\repo\docs\tmp` and +# `D:\a\tmp` cases below. None of it changes what is under test: the matcher +# decides on the presence of a drive-root `tmp` component and this path has none. run_win_payload "Write under %TEMP% (allowed)" \ - "$(write_json 'C:\Users\dev\AppData\Local\Temp\note.txt' 'x')" 0 + "$(write_json 'C:/Users//AppData/Local/Temp/note.txt' 'x')" 0 run_win_payload "Write under /var/tmp (allowed)" "$(write_json '/var/tmp/x' 'x')" 0 run_win_payload "Write repo docs/tmp (allowed)" "$(write_json 'D:\repo\docs\tmp\x.md' 'x')" 0 run_win_payload "Write relative ./tmp (allowed)" "$(write_json './tmp/x' 'x')" 0 From 158ca9eb36265f97a1e73b6c3e8ac7fce48b3278 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:22:00 -0400 Subject: [PATCH 5/9] docs(guardrails): retract the incorrect command_json pathconv finding A round-2 verifier finding claimed guardrails-test-helpers.sh's command_json omits MSYS_NO_PATHCONV and therefore rendered every MSYS `//tmp` assertion vacuous on Windows. That finding was wrong. MSYS argv rewriting converts an argument only when the argument is ENTIRELY a POSIX-absolute path. Measured on a Windows host: --arg cmd '/c/tmp/x' -> C:/tmp/x --arg cmd 'mkdir -p /c/tmp/x' -> unchanged --arg cmd 'Set-Content -Path:/c/tmp/x -Value hi' -> unchanged Every command fixture in every suite is multi-token, so nothing routed through command_json was ever mangled. Only a lone bare path is exposed, and those go through the shared write_json family, which already sets MSYS_NO_PATHCONV deliberately and documents why. There is no shared-helper defect and no cross-plugin blast radius. The local msys_command_json / msys_pwsh_command_json builders are kept: setting the variable explicitly stops a future lone-path fixture from silently becoming a drive-letter payload and ceasing to exercise the MSYS arm. Only their comment changes, to state that reason instead of the retracted one. Text only: every changed line in the test file is a comment line, and the suite still reports PASS=181 FAIL=0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- plugins/guardrails/CHANGELOG.md | 16 ++++++++++------ .../hooks/block-windows-drive-tmp.test.sh | 15 ++++++++------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 028f48883..e0986588d 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -107,12 +107,16 @@ All notable changes to the `guardrails` plugin are documented here. Format follo alternative set rather than substring-searching it (a containment test for `Edit` can never fail while `MultiEdit` passes, and a matcher with the pipes removed routes nothing while satisfying every containment check). And the MSYS - `//tmp` cases build their payloads through local no-pathconv builders: - `guardrails-test-helpers.sh`'s `command_json` omits `MSYS_NO_PATHCONV`, so on - a Windows host Git Bash rewrites `/c/tmp/x` to `C:/tmp/x` before jq sees it and - the assertion silently exercises the drive-letter alternative instead. The - shared helper is duplicated across plugins under a source-drift gate and is - not edited from here. + `//tmp` cases build their payloads through local builders that set + `MSYS_NO_PATHCONV` explicitly. MSYS argv rewriting converts an argument only + when the argument is *entirely* a POSIX-absolute path, so `/c/tmp/x` becomes + `C:/tmp/x` while `mkdir -p /c/tmp/x` passes through untouched. Every command + fixture here is multi-token and was therefore already safe through the shared + `command_json`; setting it explicitly keeps a future lone-path fixture from + silently becoming a drive-letter payload. The shared helper's path-payload + builders (`write_json` and siblings) already set it, and the helper is + duplicated across plugins under a source-drift gate, so it is not edited + from here. - **The `file-path` telemetry form is now executed, not just documented.** The existing telemetry case pipes a Bash payload; a file-path case asserts the `Write` / `Write` / `file-path` envelope and that the target path never diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index 54f62b4d4..e26bcc9ad 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -82,13 +82,14 @@ notebook_path_json() { } # Command payload builders that PRESERVE an MSYS `//tmp` spelling. -# guardrails-test-helpers.sh's command_json / pwsh_command_json omit -# MSYS_NO_PATHCONV, so on a Windows host Git Bash rewrites `/c/tmp/x` into -# `C:/tmp/x` before jq ever sees it — the payload then exercises the -# DRIVE-LETTER alternative instead of the MSYS one, and any assertion aimed at -# the MSYS matcher is vacuous exactly where it matters most. The shared helper -# is duplicated across plugins under a source-drift gate, so it is not edited -# from here; these local builders keep the MSYS cases honest on both platforms. +# MSYS argv rewriting converts an argument only when the argument is ENTIRELY a +# POSIX-absolute path: `/c/tmp/x` becomes `C:/tmp/x`, while `mkdir -p /c/tmp/x` +# passes through untouched. Every command fixture below is multi-token, so the +# shared command_json / pwsh_command_json are already safe for them and omit +# MSYS_NO_PATHCONV correctly (the shared PATH-payload builders — write_json and +# siblings — do set it, because a file_path IS a lone path). These local +# builders set it explicitly so a future lone-path command fixture cannot +# silently become a drive-letter payload and stop exercising the MSYS arm. msys_command_json() { MSYS_NO_PATHCONV=1 jq -n --arg cmd "$1" '{tool_name:"Bash",tool_input:{command:$cmd}}' } From 205fd68cce9951a51c2d736b456448b107bd67d9 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:07:44 -0400 Subject: [PATCH 6/9] fix(guardrails): run the host gate before stdin and the jq requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening block-windows-drive-tmp to Write/Edit/MultiEdit/NotebookEdit had a consequence the widening itself did not carry. The non-Windows host gate sat BELOW hook::buffer_stdin and hook::require_jq_blocking, so on a Linux or macOS host with no jq on PATH, require_jq_blocking's fail-closed exit 2 fired on EVERY file edit — on a platform where /tmp is the real POSIX temp and this guard can never find a violation. Before the widening the hook matched only Bash|PowerShell, where blocking on missing jq is the accepted #2146 posture, so the ordering was not previously reachable this way. The host gate now runs immediately after hook::check_enabled. Reading OSTYPE needs nothing from the payload, and check_enabled already exits without draining stdin, so exiting there is an established shape in this hook rather than a new one. The telemetry start stamp moves below the gate so it stays adjacent to the work it times. The Windows path is UNCHANGED. The case falls through and every fail-closed posture runs in the same order as before: buffer_stdin rc 2, jq absence, an unparseable payload, NUL bytes, MAX_COMMAND_LEN. Verified directly — a drive-root Write still exits 2 and a NUL-bearing path still exits 2 — and the pre-existing Windows-lane assertions would go red on an over-hoist. block-exported-msys-pathconv.sh keeps the opposite ordering deliberately: it matches only Bash|PowerShell, so the blast radius forcing the hoist here does not exist there. The comment says so, to stop a future author aligning them back. Regression test: removing jq from PATH is not portably simulable — an isolated bin dir without jq cannot host bash + coreutils across Git Bash and Linux, the constraint require-jq-notice-isolation.test.sh and secret-pattern-detection.test.sh both record. What decides the bug is whether the call is REACHED, so the test asserts the ordering from an xtrace of a real Linux-host run: neither buffer_stdin nor require_jq_blocking appears. Both appear before this fix and neither after. Also corrects the CHANGELOG's governance claim about the shared test helpers. They are duplicated per plugin BY CONVENTION per docs/conventions/shell-test-helpers/README.md, which places them explicitly outside check-cross-plugin-source-drift.sh's scope, with no entry in scripts/cross-plugin-source-registry.txt. Nothing gates them. Suite: PASS=183 FAIL=0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- plugins/guardrails/CHANGELOG.md | 25 ++++++++++++-- .../hooks/block-windows-drive-tmp.sh | 34 +++++++++++++++---- .../hooks/block-windows-drive-tmp.test.sh | 18 ++++++++++ 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index e0986588d..3f54a477e 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -17,6 +17,22 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `.tool_input.notebook_path`, and the early exit tests both doors. Verified repro-first: the four seeded spellings (`C:\tmp\…`, `/tmp/…`, `/c/tmp/…`, `C:/tmp/…`) exit 0 against the unmodified hook and 2 against this one. +- **A jq-less Linux or macOS host can edit files again.** Widening the matcher + to `Write`/`Edit`/`MultiEdit`/`NotebookEdit` had a consequence the widening + itself did not carry: the non-Windows host gate sat BELOW `hook::buffer_stdin` + and `hook::require_jq_blocking`, so on a host without `jq` on `PATH` every + file edit took the fail-closed `exit 2` — on a platform where `/tmp` is the + real POSIX temp and this guard can never find a violation. The host gate now + runs first, immediately after `hook::check_enabled` (which already exits + without draining stdin, so the shape is not new). Reading `OSTYPE` needs + nothing from the payload. **The Windows path is unchanged**: the case falls + through and `buffer_stdin` rc 2, jq absence, an unparseable payload, NUL + bytes and `MAX_COMMAND_LEN` all still fail closed in the same order. Removing + `jq` from `PATH` is not portably simulable (the constraint + `require-jq-notice-isolation.test.sh` records), so the regression test asserts + the ordering from an xtrace of a real Linux-host run instead: neither + `buffer_stdin` nor `require_jq_blocking` is reached. Both appear before the + fix and neither after it. - **A `tmp` directory under a single-letter parent no longer blocks.** `D:\a\tmp\x` matched: after slash-normalization the drive colon satisfied the left boundary of the MSYS `//tmp` alternative, so `d:` + `/a/tmp` read @@ -115,8 +131,13 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `command_json`; setting it explicitly keeps a future lone-path fixture from silently becoming a drive-letter payload. The shared helper's path-payload builders (`write_json` and siblings) already set it, and the helper is - duplicated across plugins under a source-drift gate, so it is not edited - from here. + duplicated per plugin **by convention**, per + `docs/conventions/shell-test-helpers/README.md` — it is explicitly outside + `check-cross-plugin-source-drift.sh`'s scope (the copies live at different + paths per plugin and are not byte-identical, so `discover` never flags them + as a cluster) and has no entry in `scripts/cross-plugin-source-registry.txt`. + Nothing gates the duplication, so it is not edited from here as a matter of + convention rather than tooling. - **The `file-path` telemetry form is now executed, not just documented.** The existing telemetry case pipes a Bash payload; a file-path case asserts the `Write` / `Write` / `file-path` envelope and that the target path never diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index 11413367a..ffa39d9f7 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -47,9 +47,36 @@ source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" hook::check_enabled "BLOCK_WINDOWS_DRIVE_TMP" +# Non-Windows hosts: /tmp is the real POSIX temp, so this guard can never find a +# violation here. Skip entirely. Tests force OSTYPE=msys to exercise the Windows +# lane on Linux CI. +# +# THIS GATE RUNS FIRST, ahead of hook::buffer_stdin and hook::require_jq_blocking, +# and that ordering is load-bearing. This hook matches Write / Edit / MultiEdit / +# NotebookEdit as of 0.30.0. Evaluated any later, a Linux or macOS host without +# jq on PATH would take require_jq_blocking's fail-closed exit 2 on EVERY file +# edit — denying writes on a platform where the guard has no opinion at all. +# Reading OSTYPE needs nothing from the payload, so the gate is free to precede +# the read; hook::check_enabled above already exits without draining stdin, so +# that is an established shape in this hook, not a new one. +# +# The Windows path is UNCHANGED: the case falls through and every fail-closed +# posture below runs in exactly the same order as before — buffer_stdin rc 2, +# jq absence, unparseable payload, NUL bytes, MAX_COMMAND_LEN. +# +# block-exported-msys-pathconv.sh deliberately keeps the opposite ordering. It +# matches only Bash|PowerShell, where blocking on missing jq is the accepted +# #2146 posture; the blast radius that forces the hoist here does not exist +# there. Do not "fix the inconsistency" by aligning them. +case "${OSTYPE:-}" in +msys* | cygwin* | win32) ;; +*) exit 0 ;; +esac + # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on older bash it is unset, so default to empty and skip telemetry (the block # still fires). Referencing it bare under `set -u` would abort before exit. +# Below the host gate so it stays adjacent to the work it actually times. start=${EPOCHREALTIME:-} # hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read. rc 1 @@ -105,13 +132,6 @@ FILE_PATH="${HOOK_JQ_FIELDS[2]:-}" # COMMAND alone, which is exactly how a `Write` payload passed unexamined. [[ -n "$COMMAND" || -n "$FILE_PATH" ]] || exit 0 -# Non-Windows hosts: /tmp is the real POSIX temp. Skip entirely. Tests force -# OSTYPE=msys to exercise the Windows lane on Linux CI. -case "${OSTYPE:-}" in -msys* | cygwin* | win32) ;; -*) exit 0 ;; -esac - MAX_COMMAND_LEN=16384 emit_tel() { diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index e26bcc9ad..664fb88b8 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -103,6 +103,24 @@ run_posix_host "Linux host: mkdir /tmp/x allowed" 'mkdir -p /tmp/x' run_posix_host_payload "Linux host: Write /tmp/x allowed" "$(write_json '/tmp/x' 'body')" run_posix_host_payload "Linux host: Edit /tmp/x allowed" "$(edit_json '/tmp/x' 'body')" +# The host gate must be reached BEFORE hook::buffer_stdin and +# hook::require_jq_blocking. Widening the matcher to Write/Edit/MultiEdit/ +# NotebookEdit made the old ordering a hard break: on a Linux or macOS host with +# no jq on PATH, require_jq_blocking's fail-closed exit 2 fired on EVERY file +# edit, on a platform where this guard can never find a violation. +# +# Removing jq from PATH is not portably simulable — an isolated bin dir without +# jq cannot host bash + coreutils across Git Bash and Linux, the same constraint +# require-jq-notice-isolation.test.sh and secret-pattern-detection.test.sh both +# record. Do not re-attempt it. What is portable, and what actually decides the +# bug, is whether the call is REACHED at all: `command -v jq` can only deny a +# write on a jq-less host if control flow gets to it. So assert the ordering +# directly from an xtrace of the real run. Both names appear in this trace +# before the fix and neither appears after it. +trace=$(env OSTYPE=linux-gnu bash -x "$HOOK" <<<"$(write_json '/home/u/x.txt' 'body')" 2>&1 >/dev/null) +assert_absent "Linux host: stdin is never buffered" "$trace" "buffer_stdin" +assert_absent "Linux host: the blocking jq requirement is never reached" "$trace" "require_jq_blocking" + # --- File-path lane: drive-root targets (blocked) ---------------------------- # The 2026-08-30 incident payload: an empty C:\tmp\tmp.rSFIkHm5DO that no guard saw. run_win_payload "Write C:\\tmp\\tmp.rSFIkHm5DO (blocked)" \ From 1bddd989c3f8415dff3c9fad213f231edc9f2ac7 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:24:46 -0400 Subject: [PATCH 7/9] docs(guardrails): correct the jq-simulation claim and record the drain trade-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 verification falsified a claim in 205fd68's own prose. I wrote that removing jq from PATH is not portably simulable and told a future author not to re-attempt it. It is simulable wherever jq lives outside the directory holding bash and coreutils: pruning that one PATH entry leaves the shell intact without jq. Done that way, the motivation is confirmed directly rather than by proxy — the parent commit exits 2 on a Linux Write and 205fd68 exits 0 on a real jq-less PATH. The constraint the repo actually records is portability, not impossibility: where jq sits in /usr/bin beside bash, pruning takes the shell with it. So the committed assertion still uses the portable xtrace form, and the comment now says why rather than overstating it. Also records two things the verification surfaced: - The non-Windows gate exits without draining stdin, so a payload past roughly 64KB is left undrained on Linux and macOS (measured: writer rc 141 at 256KB and 1MB; Windows lane unaffected). The shape was established by hook::check_enabled, but that is a kill switch firing only when a guard is disabled, whereas this gate fires on every non-Windows tool call — so the exposure is new even though the shape is not. Accepted rather than fixed: a naive builtin drain blocks until EOF and can hang a tool call, and draining via buffer_stdin first would put its rc-2 fail-closed exit back ahead of the host gate, which is the bug 205fd68 fixed. Filed as #3504. - On a non-Windows host this guard no longer fails closed on jq's absence for the Bash lane either. That posture is kept in full on Windows and dropped only where every exit 2 it produced was a false positive by construction. Stated as its own CHANGELOG bullet rather than left implicit. Comment and prose only: every changed line in both shell files is a comment line. Suite unchanged at PASS=183 FAIL=0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- plugins/guardrails/CHANGELOG.md | 19 +++++++++++------ .../hooks/block-windows-drive-tmp.sh | 11 ++++++++++ .../hooks/block-windows-drive-tmp.test.sh | 21 ++++++++++++------- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 3f54a477e..4787c4b6b 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -27,12 +27,19 @@ All notable changes to the `guardrails` plugin are documented here. Format follo without draining stdin, so the shape is not new). Reading `OSTYPE` needs nothing from the payload. **The Windows path is unchanged**: the case falls through and `buffer_stdin` rc 2, jq absence, an unparseable payload, NUL - bytes and `MAX_COMMAND_LEN` all still fail closed in the same order. Removing - `jq` from `PATH` is not portably simulable (the constraint - `require-jq-notice-isolation.test.sh` records), so the regression test asserts - the ordering from an xtrace of a real Linux-host run instead: neither - `buffer_stdin` nor `require_jq_blocking` is reached. Both appear before the - fix and neither after it. + bytes and `MAX_COMMAND_LEN` all still fail closed in the same order. + Confirmed on a real jq-less `PATH`: the previous commit exits 2 on a Linux + `Write` and this one exits 0. That simulation is host-dependent (it needs + `jq` in a directory that does not also host `bash` and coreutils), which is + the portability constraint `require-jq-notice-isolation.test.sh` records, so + the committed regression test does not rely on it and asserts the ordering + from an xtrace instead: neither `buffer_stdin` nor `require_jq_blocking` is + reached. Both appear before the fix and neither after it. +- **Deliberate posture change, stated rather than silent:** on a NON-Windows + host this guard no longer fails closed on `jq`'s absence, for the Bash lane + either. That #2146 posture is kept in full on Windows. It is dropped only + where the guard has no opinion at all — `/tmp` is the real POSIX temp there, + so every exit 2 it produced was a false positive by construction. - **A `tmp` directory under a single-letter parent no longer blocks.** `D:\a\tmp\x` matched: after slash-normalization the drive colon satisfied the left boundary of the MSYS `//tmp` alternative, so `d:` + `/a/tmp` read diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index ffa39d9f7..4155e4816 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -60,6 +60,17 @@ hook::check_enabled "BLOCK_WINDOWS_DRIVE_TMP" # the read; hook::check_enabled above already exits without draining stdin, so # that is an established shape in this hook, not a new one. # +# Known and accepted: the shape is established, the EXPOSURE is not. check_enabled +# is a kill switch that fires only when an operator disables the guard, whereas +# this gate fires on every non-Windows tool call. A payload past roughly 64KB is +# therefore left undrained on Linux and macOS where it previously was not, so a +# writer that does not handle EPIPE would take SIGPIPE. Accepted rather than +# fixed, because the alternatives are worse: a naive builtin drain blocks until +# EOF and can hang a tool call (which is the whole reason hook::buffer_stdin is +# a bounded idle-timeout read), and draining via buffer_stdin first would put +# its rc-2 fail-closed exit back in front of the host gate — the very bug above. +# Tracked separately rather than widened into this change. +# # The Windows path is UNCHANGED: the case falls through and every fail-closed # posture below runs in exactly the same order as before — buffer_stdin rc 2, # jq absence, unparseable payload, NUL bytes, MAX_COMMAND_LEN. diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index 664fb88b8..116d09f79 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -109,14 +109,19 @@ run_posix_host_payload "Linux host: Edit /tmp/x allowed" "$(edit_json '/tmp/x' ' # no jq on PATH, require_jq_blocking's fail-closed exit 2 fired on EVERY file # edit, on a platform where this guard can never find a violation. # -# Removing jq from PATH is not portably simulable — an isolated bin dir without -# jq cannot host bash + coreutils across Git Bash and Linux, the same constraint -# require-jq-notice-isolation.test.sh and secret-pattern-detection.test.sh both -# record. Do not re-attempt it. What is portable, and what actually decides the -# bug, is whether the call is REACHED at all: `command -v jq` can only deny a -# write on a jq-less host if control flow gets to it. So assert the ordering -# directly from an xtrace of the real run. Both names appear in this trace -# before the fix and neither appears after it. +# Removing jq from PATH is simulable only where jq lives in a directory that +# does not ALSO host bash and coreutils — prune that one entry and the shell +# survives without jq. That holds on some hosts (it was demonstrated on a Cygwin +# host with a single jq location, where the parent commit exits 2 and this one +# exits 0 on a real jq-less PATH) and not on others: where jq sits in /usr/bin +# beside bash, pruning it takes the shell with it. That is the portability +# constraint require-jq-notice-isolation.test.sh and +# secret-pattern-detection.test.sh both record, so the assertion below does not +# depend on it. What IS portable, and what decides the bug either way, is +# whether the call is REACHED: `command -v jq` can only deny a write on a +# jq-less host if control flow gets to it. So assert the ordering from an +# xtrace of the real run. Both names appear in this trace before the fix and +# neither appears after it. trace=$(env OSTYPE=linux-gnu bash -x "$HOOK" <<<"$(write_json '/home/u/x.txt' 'body')" 2>&1 >/dev/null) assert_absent "Linux host: stdin is never buffered" "$trace" "buffer_stdin" assert_absent "Linux host: the blocking jq requirement is never reached" "$trace" "require_jq_blocking" From 55225d389c0fa8c4ebeb8a65d826eb4357647a48 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:39:46 -0400 Subject: [PATCH 8/9] fix(guardrails): spell unparsable per the repo typos gate The hygiene lane's typos check rejects `unparseable`. Prose only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- plugins/guardrails/CHANGELOG.md | 2 +- plugins/guardrails/hooks/block-windows-drive-tmp.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 4787c4b6b..cd1cd967c 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -26,7 +26,7 @@ All notable changes to the `guardrails` plugin are documented here. Format follo runs first, immediately after `hook::check_enabled` (which already exits without draining stdin, so the shape is not new). Reading `OSTYPE` needs nothing from the payload. **The Windows path is unchanged**: the case falls - through and `buffer_stdin` rc 2, jq absence, an unparseable payload, NUL + through and `buffer_stdin` rc 2, jq absence, an unparsable payload, NUL bytes and `MAX_COMMAND_LEN` all still fail closed in the same order. Confirmed on a real jq-less `PATH`: the previous commit exits 2 on a Linux `Write` and this one exits 0. That simulation is host-dependent (it needs diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index 4155e4816..fc1ad68ef 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -73,7 +73,7 @@ hook::check_enabled "BLOCK_WINDOWS_DRIVE_TMP" # # The Windows path is UNCHANGED: the case falls through and every fail-closed # posture below runs in exactly the same order as before — buffer_stdin rc 2, -# jq absence, unparseable payload, NUL bytes, MAX_COMMAND_LEN. +# jq absence, unparsable payload, NUL bytes, MAX_COMMAND_LEN. # # block-exported-msys-pathconv.sh deliberately keeps the opposite ordering. It # matches only Bash|PowerShell, where blocking on missing jq is the accepted From b11d006ddb81def4da8a994305bcedb7aea0995d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:25:36 -0400 Subject: [PATCH 9/9] fix(guardrails): drop the user path from the host-gate regression fixture The machine-specific-paths hygiene gate rejects `/home/u/x.txt` as a Linux user path. The assertion only needs a path that is not a drive-root temp target on a Linux host, so it needs no home directory at all: `/srv/app/ notes.txt` satisfies the gate without a placeholder. Preferred over the `` placeholder spelling used by the %TEMP% fixture. That fixture is exercising a real platform temp path and has to look like one; this one is not, so the simpler literal is better than templating a directory the test never needed. Suite unchanged at PASS=183 FAIL=0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- plugins/guardrails/hooks/block-windows-drive-tmp.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index 116d09f79..b9a40410c 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -122,7 +122,7 @@ run_posix_host_payload "Linux host: Edit /tmp/x allowed" "$(edit_json '/tmp/x' ' # jq-less host if control flow gets to it. So assert the ordering from an # xtrace of the real run. Both names appear in this trace before the fix and # neither appears after it. -trace=$(env OSTYPE=linux-gnu bash -x "$HOOK" <<<"$(write_json '/home/u/x.txt' 'body')" 2>&1 >/dev/null) +trace=$(env OSTYPE=linux-gnu bash -x "$HOOK" <<<"$(write_json '/srv/app/notes.txt' 'body')" 2>&1 >/dev/null) assert_absent "Linux host: stdin is never buffered" "$trace" "buffer_stdin" assert_absent "Linux host: the blocking jq requirement is never reached" "$trace" "require_jq_blocking"