fix(guardrails): fail closed when jq is missing in the irreversible-operation guards - #2178
Conversation
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 1m 54s —— View job Security review completeTodo list
ScopeReviewed the full diff ( FindingsNo security issues found. Notes from the review (informational, not findings):
Per instructions, deferred to zizmor's advisory lane for supply-chain/unpinned-action, dangerous-trigger, permission, and template-injection concerns — none of the changed files here are GitHub Actions workflows. |
|
Claude finished @kyle-sexton's task in 5m 2s —— View job Code review complete
Findings (2 inline)
What held up well
|
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
2324478 to
4852651
Compare
|
Warning Automated security review did not complete — this is an infrastructure failure, not a review verdict. Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."
The check is green on purpose, and it is not evidence. It certifies that a security pass ran, and this one did not complete — but the cause is outside this PR's control, so merging is deliberately left unblocked rather than locking every merge for the length of the outage. Nothing was reviewed at this head. Where this check is required, it is satisfied without that evidence; a human should review security-sensitive changes here before merging. Re-run the job to retry the review; a new push also retries it only if the caller's Re-running does NOT help for every class:
|
|
Warning Automated review did not complete — this is an infrastructure failure, not a review verdict. Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."
Re-run the job to retry the review. A new push re-triggers this lane only if the caller's |
…control 0.51.11 Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
…peration guards Rebased onto origin/main: applied hook::require_jq_blocking to lib/hook-utils.sh, synced 16 plugin copies, bumped carrying plugin versions, and updated guardrails block-dangerous-git and block-no-verify to use the fail-closed gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Main's block-no-verify and block-dangerous-git carry HOOK_JQ_FIELDS_NUL handling that fbb8a5c dropped. Merge main's hook bodies with require_jq_blocking calls and correct hook_id/option arguments. Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
…mment Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
1de1635 to
8b4e3e2
Compare
…rge claim and conform per_page=100 (#2263) Delivers the `review` / `work-items` / `claude-ops` parts of #2246. The `source-control` sites in that issue's table are owned by a parallel lane and are **not** touched here. ## Part 1 — the mechanism claim `plugins/review/agents/ci-log-auditor.md:65` said `/annotations` pages are "concatenated" arrays, "so they are combined with `add`". That is not what `gh` does. Measured on **gh 2.95.0**, against `melodic-software/claude-code-plugins`: | read | `jq -s 'length'` | |---|---| | `issues/2163/comments?per_page=5` (bare array, 14 items, no `--jq`) | **1** | | `commits/<sha>/check-runs?per_page=5` (object envelope, no `--jq`) | **6** | | `issues/2163/comments?per_page=5` **with** `--jq '[.[]]\|length'` | **3** (prints `5 5 4`) | So there are two branches, not one: - **No `--jq`** — `gh` merges array-shaped pages into ONE JSON array. `jq -s` yields a *one-element* slurp and `add` unwraps it. It is not concatenating pages. - **With `--jq`** — the merge is suppressed and each page is filtered and emitted separately. This is the case the file already warns about nine lines above (`` `--jq` runs per page ``), so the old prose contradicted its own neighbour. The **published command is correct** — verified: `jq -s 'add | length'` returns 14, matching the endpoint's actual item count. Only the explanation was wrong, in a file whose whole subject is being factually right about pagination. The prose now states both branches and names the condition that selects between them. **Honesty note:** the bare-array behavior was measured on `issues/<n>/comments`, not on `/annotations` itself. Bare-array shape is the property that carries the claim, and `/annotations` is documented as a bare array, but the generalization is stated rather than measured at that endpoint. The identical wrong claim sat above the identical `jq -s 'add'` fold in `plugins/claude-ops/skills/lanes/scripts/telemetry-upsert.sh`, so it is corrected in the same pass (that file is edited here anyway for Part 2). ## Part 2 — `--paginate` without `per_page=100` Seven sites, all re-verified against `main` `33f0df5` before editing: | file | line | |---|---| | `plugins/claude-ops/skills/lanes/scripts/restart-consumer.sh` | 774 | | `plugins/claude-ops/skills/lanes/scripts/telemetry-upsert.sh` | 344 | | `plugins/work-items/skills/attend-queue/SKILL.md` | 136 | | `plugins/work-items/skills/work-loop/reference/telemetry-upsert.md` | 43 | | `plugins/work-items/tools/work-item-tracker/adapters/github/common.sh` | 189 | | `plugins/work-items/tools/work-item-tracker/adapters/github/reclaim.sh` | 46, 50 | Stated plainly: **these were not truncation bugs.** `--paginate` fetches every page regardless. They were non-conformant with rule 1 as `plugins/source-control/skills/pull-request/reference/readiness.md:55` publishes it, and cost 3.3x the requests. ### Behavior-change check (the live shell) `common.sh:189` and `reclaim.sh:46,50` are consumed by downstream folds, so page size could in principle change the answer. It does not — because `gh` applies `--jq` **per page** under any page size, and each fold is page-shape agnostic. Measured end-to-end at both sizes: - `--jq '[.[]|{id}]' | jq -s 'add // []'` → **14 items** at `per_page=5` *and* at `per_page=100` (`common.sh` shape) - `--jq '[.[]]|length' | jq -s 'add // 0'` → **14** at `per_page=5` *and* at `per_page=100` (`reclaim.sh` shape) `restart-consumer.sh:774` uses an element-wise `-q` projection into `jq -s`; `telemetry-upsert.sh:344` reads raw and slurps. Both are safe under either shape. `wit_run_gh` was checked before appending a query string: it is a transparent `gh` pass-through with no endpoint parsing, so the `?` survives it. ### One required companion edit to a test `plugins/claude-ops/skills/lanes/scripts/telemetry-upsert.test.sh`'s `gh` stub matched the list endpoint with `[[ "$url" == */comments ]]` — an **exact suffix** match. With `?per_page=100` appended it would have fallen through to a bare `exit 0`, silently serving an empty comment list. The matcher now admits the query form explicitly (`*/comments'?'*`, written that way so a bare `?` glob does not also swallow `/commentsX`). This is not the "fixture editing" #2246 excludes — it is what makes the production change correct rather than silently green. Checked and **not** needing the same treatment: `lease-coordination.test.sh` (`*"/timeline"*` and `*"--paginate"*` are substring matches) and `restart-consumer.test.sh` (`*"/comments"*` substring, and its URL assertions are `assert_contains`). ## Deliberately not edited - `plugins/claude-ops/skills/morning-brief/scripts/morning-brief.sh:508` and `plugins/work-items/tools/work-item-tracker/adapters/github/README.md:205` — `gh api graphql --paginate`, cursor-paginated with `first: 100` in the query. `per_page` does not apply. **Agreed exclusion.** Note that `morning-brief.sh:553`'s comment ("`--paginate` concatenates one JSON document per page") is *correct* there: GraphQL responses are object documents, so no array merge happens. - `plugins/work-items/tools/work-item-tracker/adapters/github/README.md:106-113` — already carries `per_page=100` and its explanation is about `--jq` per-page behavior, which is accurate. Its "collects the pages" phrasing is loose but makes no `add` mechanism claim. Reported, not edited. - Test fixtures and CHANGELOG prose, per the issue. - **`plugins/source-control/**` — untouched.** Another lane owns it. ### Reported, not fixed (out of lane) `plugins/source-control/skills/babysit-loop/reference/telemetry-upsert.md` is a near-twin of the `work-items` file edited here. They are **not** a registered cross-plugin cluster (different path-within-plugin, so `scripts/check-cross-plugin-source-drift.sh` never clusters them) and they already diverge substantially in prose, so editing one is not a gate failure. But the `source-control` copy's `--paginate` line is on #2246's table under that lane's ownership — flagging it so it is not lost. ## Versions `review` 0.18.3 → **0.18.4** · `work-items` 0.35.2 → **0.35.3** · `claude-ops` 0.29.0 → **0.29.1** Each gets one new `## [<version>]` section; every pre-existing section keeps its own heading and body. Verified by heading-list diff against `33f0df5` (exactly one addition, zero deletions, per plugin) and a byte-identical-tail hash from the previous head heading down. `claude-ops`'s number is contended by open PR #2178, which bumps the same manifest for an unrelated `hook-utils.sh` materialization. Re-resolved against `main` immediately before merge. ## Verification All four conflict-marker forms swept — clean. `check-changelog-parity.sh --check` and `--check-bump origin/main` pass locally. Affected test suites run. Every change is discharged by an independent fresh-context verifier against a pinned SHA, with the `gh --paginate` shape claim re-measured by the verifier rather than taken from this description. ## Related - #2246 — the parent issue. **No linked issue** is closed by this PR: #2246 also covers `plugins/source-control/**`, which a parallel lane owns, so it stays open until that lane's PR lands. - #2178 — bumps `plugins/claude-ops/.claude-plugin/plugin.json` for an unrelated `hook-utils.sh` materialization, so it contends for the same `claude-ops` version number. Re-resolved against `main` immediately before merge. - #2239 — already fixed `ci-log-auditor.md:22`'s missing `per_page=100` on `main`; deliberately not re-touched here. Refs #2246 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
hook::require_jqwascommand -v jq && return 0, else a once-per-session notice andexit 0—the whole hook skipped, the tool call proceeds. Measured against
origin/main, with the jq-presentcolumn as the discrimination control:
The same two scripts fail closed on the other input they cannot parse: above
MAX_COMMAND_LEN(16384) a command is treated as obfuscation and blocked unread. Two opposite postures toward "I
cannot read this input" in one file — so an author who could not fit a dangerous command under the
ceiling could simply be on a machine without
jq.The disposition: fail CLOSED, scoped to the irreversible-operation guards
After (same harness, same machine):
The (safe, jq HIDDEN) = DENY cell is a real cost, not an oversight. These guards run on every
Bash/PowerShell tool call; without
jqthey cannot read the command at all, so they cannot tell adangerous one from a safe one and deny both. On a
jq-less machine every matched tool call isblocked until
jqis installed or the guard's kill switch is set. That is the hard dependencyoption 2 named. Option 3 (a
jq-free substring pre-check) was rejected and is not implemented.The kill switch is still a real exit:
hook::check_enabledruns before the gate, soblock_dangerous_git_enabled=falsebypasses the guard on ajq-less machine. Asserted.Which hooks are in the class — mechanical, not a taste judgement
The criterion is internal consistency: a hook is fail-closed iff it already fails closed on
another unparsable-input condition. Today that is a
MAX_COMMAND_LENceiling, and repo-wide thatis exactly two files —
block-dangerous-git.shandblock-no-verify.sh, the two the issue names.That is not a coincidence: intra-script contradiction is what #2146 reports, and a script with no
length-ceiling posture has no contradiction to resolve.
Considered and deliberately excluded, so this is not a silent scoping choice:
block-hook-bypasscat > path), trivially reversible, and has no length ceilingblock-noncanonical-commit--amendsecret-pattern-detection,hardcoded-path-check,block-convention-violationrequire_jqcallers (context-guard/zone-gate,source-control/pr-*-gate,autonomy/lane-stop-gate)Severity is a slope; "already fails closed elsewhere in the same script" is a line.
require-jq-posture.test.shpins the membership both ways, so a hook that grows a ceiling and keepsthe fail-open gate fails, and so does a hook that adopts the blocking gate without one.
Helper design: a sibling function, not a parameter
hook::require_jq_blockingalongside the unchangedhook::require_jq.Why not a flag on the existing function: a parameter's omitted value has to default to
something, and the safe-looking default (fail open, today's behaviour) means a guard that should
fail closed but whose flag someone forgot fails open silently — which is the exact defect this
PR fixes, reintroduced at the API. Two names make the posture greppable, make the fail-closed path
impossible to reach by accident, and make omission a visible choice.
Why not branch at the call sites: the issue's own acceptance says the reasoning belongs at the
helper, and a call-site branch leaves the decision point still unexplained. (It also would not have
avoided the 16 plugin bumps:
sync-hook-utils.sh --check-bumpis content-based, so even acomment-only lib edit requires them.)
The reasoning is at the helper. One
TWO POSTURES, AND WHY THERE ARE TWOblock sits above bothfunctions — why fail-open is the default, why a minority must not be, the membership criterion, the
exclusions, the disclosed cost, and why two functions rather than a flag. The call-site comments now
say "this asserts the behaviour; that explains it", and the posture test asserts the block is
actually there.
The control that FAILS against current
mainrequire-jq-posture.test.shwas run unchanged againstorigin/main's guardrails plugin(
git archive origin/main plugins/guardrails, hashes verified equal toorigin/main's blobs):with the four cells against
mainreproducing the issue's table exactly (DENY/ALLOWoverALLOW/ALLOW). Against this branch: PASS=36 FAIL=0.How
jqwas hidden — and how that measurement was kept honestA
BASH_ENVfile defines acommandshell function that reportsjqabsent and forwards everyother lookup to the real builtin, plus a
jqfunction that fails like a missing binary.PATHisuntouched. Stripping
PATHdirectories also removesgit, which these guards invoke, and a guardthat cannot find
gitproduces the same ALLOW for an entirely unrelated reason.The suite prints a precondition line measured inside the hidden environment and refuses to read a
verdict until it holds:
path-to-jq=intactisbuiltin command -v jqstill resolving — proof the lookup was hidden andthe tool was not removed. Every
jqprobe inhook-utils.shis acommand -v jq(verified: 7sites, all of that form), so the override reaches all of them. A second check sources the real
hook-utils.shinside the hidden environment and asserts the gate's own predicate sees nojq,plus the inverse without the override.
The harness caught itself once. The first
origin/mainrun archived onlyplugins/guardrails/hooks, soblock-dangerous-git.shcould not source its bundled PowerShellclassifier from
<plugin-root>/liband exited early — producingALLOWin all four cells,including
jq PRESENT / dangerous push. The jq-present discrimination control is what flagged it asa broken harness rather than a measured result. Fixed by archiving the whole plugin. This is the
failure mode the issue says invalidated three prior attempts.
Proof the fixture reached the path under test
Under
jqhidden, the denial is asserted to be the new path and not some unrelated failure:jqas the missing prerequisite;https://jqlang.org/download/;block_dangerous_git_enabled/block_no_verify_enabled);hook skipped for this sessionasserted absent).And the advisory control asserts the inverse — that the fail-closed denial text is absent from every
advisory hook's stderr.
Blast radius
sync-hook-utils.sh --check-bumprequires every carrying plugin to bump when the shared lib changes,so all 16 are bumped with a CHANGELOG entry (the precedent set by b20e70a / #2147). The 15
non-guardrails entries state honestly that the lib gained a fail-closed sibling with no behaviour
change in that plugin — nothing outside
guardrailscalls it.guardrailstakes a minor bump(
0.24.1→0.25.0), not a patch: it now denies calls it previously allowed.origin/mainmoved under this branch mid-flight — #2135 landed the same 16-plugin lib bump for itsNUL-byte fix, so every version collided. Resolved by taking main's side of every CHANGELOG and
manifest wholesale and re-applying this change's entry and bump on top, so main's entries survive
intact and every plugin here is strictly above what main now carries.
block-no-verify.shconflictedtextually on the very comment both changes rewrote; both survive (see the merge commit message).
require-jq-notice-isolation.test.shneeded one adjustment: its discovery matchedhook::require_jqas a substring and so would have swept inhook::require_jq_blocking, whosecallers have no notice key to collide. The match is now anchored, and the test's own subject is
documented as not applying to the blocking gate.
Verification run on this branch
require-jq-posture.test.sh(new)origin/mainrequire-jq-notice-isolation.test.shblock-no-verify.test.shblock-dangerous-git.test.shscripts/sync-hook-utils.sh --check/--check-bump origin/mainscripts/check-changelog-parity.sh --check/--check-bump origin/mainscripts/check-silent-skips.shscripts/check-shell-portability.sh --paths <changed>shellcheck -x -S warning <changed>Merge-order note
This PR and #2167 both bump
plugins/source-control(this one because it carries the sharedhook-utils.sh). Main is at0.51.5; #2167 now claims0.51.6and this PR claims0.51.7, soit stays strictly greater either way. Merge #2167 first — it is one plugin and cheaper to redo.
If this one lands first instead, #2167 must re-bump to
0.51.8.Closes #2146
Related
source-controlversion bump