fix(guardrails): block --force-with-lease forms that state no expected value - #1275
Conversation
…d value The guard treated every `--force-with-lease` spelling as safe force. Two of them are not, by git's own account. `--force-with-lease` and `--force-with-lease=<refname>` state no expected value, so git leases against the remote-tracking ref. git-push(1), under "A general note on safety", says that form "interacts very badly with anything that implicitly runs `git fetch` on the remote to be pushed to in the background" and that the protection is "trivially defeated if some background process is updating refs in the background" — the lease is satisfied by a ref some other process fetched, and the push clobbers work the pusher never saw. That is the same failure mode `--force` has, reached by a slower road. Only `--force-with-lease=<refname>:<expect>` states the expectation explicitly, and it is the one form git does not mark experimental. Those two no-expected-value forms are now blocked under a new `push-lease-unsafe` token, unless `--force-if-includes` (git 2.30+) is present — git's documented mitigation for exactly these forms, which it declares a no-op alongside an explicit `:<expect>`. ## What still passes - `--force-with-lease=<refname>:<expect>`, including an empty `<expect>` (which asserts the ref must not exist — still explicit). - Any lease form paired with `--force-if-includes`. - A push dry-run, which disarms the whole check as before. ## Detection detail Unique-prefix abbreviations are handled: `--force`, `--force-with-lease` and `--force-if-includes` share the `--force` prefix, so `--force-w` and `--force-i` are the shortest spellings git accepts, and both are matched. A shorter `--forc` is ambiguous and git rejects it outright, which is why the exact `--force` arm needs no abbreviation handling. After `--`, words are operands rather than flags, so a literal `--force-if-includes` refspec does not disarm the check. ## Why the hook and not the permission list Claude Code permission rules are whole-string globs with `*` as the only metacharacter, and deny always beats allow, so a deny rule cannot carry allowlist exceptions. Denying the unsafe lease forms while allowing `=<ref>:<expect>` is therefore not expressible there — and the docs name a PreToolUse hook as the mechanism for what globs cannot express. This is that hook. ## Testing `block-dangerous-git.test.sh`: 261 pass, 0 fail — 14 new cases covering bare, `=<refname>`, `=<refname>:<expect>`, empty `<expect>`, both abbreviations, `--force-if-includes` alone and paired, dry-run, and the `--` operand boundary. Three existing cases asserted the old permissive behavior and were updated to the new contract. shellcheck clean at the repo ruleset; markdownlint clean.
|
Claude finished @kyle-sexton's task in 3m 56s —— View job Code Review
The implementation is well-reasoned and the logic is correct. The pre-scan / main-scan dual-pass design correctly handles ordering (e.g. Two minor findings and one nitpick below. Finding 1 —
|
|
Claude finished @kyle-sexton's task in 4m 15s —— View job Security Review — PR #1275
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1a75883e1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Classification of the code review findings, validated against the PR head
Fixes are on the way in a follow-up commit; I will cite the SHA here once pushed. |
|
Classification of the security review finding, validated against the PR head
Fix applied is exactly the symmetric reset you proposed — |
…disarm the lease check The lease check treated the mitigation flag as set-once. `git push -h` on git 2.54.0 lists the option as `--[no-]force-if-includes`, so the negation is a real spelling git accepts and applies last-wins. The pre-scan's `--no-*` arm reset only `dry` and ended with `;;`, so `--no-force-if-includes` reached neither the arm that sets `if_includes=1` nor any reset — a preceding `--force-if-includes` left the flag latched. The main scan's `((!if_includes))` guard then skipped the `push-lease-unsafe` block while git pushed with the mitigation off, permitting the exact unseen-work clobber the check was added to block. The `--no-*` arm now clears the flag the same way it clears `dry`, so the mitigation is last-wins in argument order. Regression tests cover the bypass command, the re-armed opposite order, the abbreviated negation `--no-force-i`, and negative guards proving `--no-force-w` and `--no-dry-run` leave the mitigation intact. Also declares `if_includes` in `check_segment`'s `local` list alongside every sibling scan variable — no live leak today, since the reset precedes each read, but the omission would become one if the reset ever moved — adds allow-list contract tests pinning the `push-lease-unsafe` token in both directions, and lists the unsafe lease forms in the guard's toggle description. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 31s —— View job Code Review — PR #1275
All four findings from the prior code review and security review have been addressed in Finding 1 —
|
|
Claude finished @kyle-sexton's task in 3m 44s —— View job Security Review — PR #1275
No security issues foundThe IMPORTANT finding from the previous security review ( Fix verification —
--no-*)
abbrev_match "dry-run" "--${x#--no-}" 2 && dry=0
abbrev_match "force-if-includes" "--${x#--no-}" 7 && if_includes=0
;;The
Abbreviated negation ( Negative guard ( Negative guard ( Five regression tests on lines 48–52 lock in all four variants (negated-after-set, re-armed, abbreviated, wrong-suffix negative guards). The previously exploitable command Other surfaces reviewed (all clean)
|
|
All four findings from the code review and the security review are fixed in
Verification: full hook suite 268 passed, 0 failed; Remaining blocker: the branch is behind |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e0bfe61d44
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`--force-with-lease=<refname>:<expect> --no-force-if-includes` must pass: the expectation is stated explicitly, so the lease does not depend on the mitigation and negating it changes nothing. The negation cases added alongside cover the paths where the mitigation is load-bearing; this one pins the path where it is not.
Resolves the `guardrails` version collision: main shipped 0.14.3 (a docs-only change) while this branch carries 0.15.0 for the lease-form distinction. Both entries are kept, 0.15.0 stacked above 0.14.3, and the manifest stays at 0.15.0 since it is the higher of the two. Merged rather than rebased so the branch stays fast-forwardable — the force-push a rebase would need is denied by policy, and correctly so.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 91e612517a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… match Review found `--force-with-lease --no-force-with-lease` blocked when it should pass: git documents the option as `--[no-]force-with-lease` and applies the last spelling, so a trailing negation means the command is not a lease push at all. The root cause was structural rather than a missing arm. Every option in this family is last-wins — the lease, the `--force-if-includes` mitigation, and `--dry-run` — but only the latter two were tracked across the segment while the lease was acted on the moment it matched. No single occurrence can decide anything until the segment ends. The pre-scan now tracks lease state as 0 (absent), 1 (no stated expectation) or 2 (`=<refname>:<expect>` stated), and the block decision runs once, after the scan, alongside the existing dry-run early return. The `--force-*` arm in the second loop is gone; it was a second site that could fire on a state later words would have changed. Two things this surfaced, both worth stating because they are easy to reintroduce: the `--no-*` arm has to consume its word, or `--no-force-with-lease` falls through and re-matches below as the positive option, undoing the clear it just made; and a negation of one family member must not clear another, so `--no-force-w` leaves the mitigation intact. ## Testing `block-dangerous-git.test.sh`: 273 pass, 0 fail. Four new cases cover the negated lease, its re-arm, a bare negation with nothing to cancel, and a stated expectation negated. shellcheck clean; markdownlint clean.
|
Claude finished @kyle-sexton's task in 5m 15s —— View job Code Review — PR #1275
Reviewed the changes in What changedThe previous pre-scan acted on the lease the moment it matched a lease-family option — the decision was mid-scan rather than deferred. This meant
The block decision is now made once, after the full pre-scan, in the same place as the Correctness — the logic traces correctlyKey flows through the new
The cross-family separation is correct: The The four new test cases confirm the fixed behaviors: negated lease allowed, re-armed lease blocked, bare negation alone allowed, explicit-expectation negated allowed. Finding — second Codex P2 still partially open (low severity)The Codex P2 finding on
In the new implementation, the last-wins model resolves this one way only:
In git, per-ref explicit leases and the bare fallback are additive, not last-wins against each other — the explicit entry for This is a conservative miss, not a security bypass. The only fix would require collecting all pushed refspecs and matching them against per-ref leases to determine whether every ref is covered — substantially more complexity than this PR should carry. Worth a comment in the code or a test noting the known limitation, so a future reader understands why the # Note: `--force-with-lease=ref:sha --force-with-lease` is treated as lease=1
# (last-wins over the single state variable). Git treats them as additive, not
# last-wins, so this is a conservative false positive for that specific order.
# Resolving it would require tracking per-ref expectations against pushed
# refspecs, which is out of scope here.Everything else looks correct
The fix for the first Codex P2 ( |
|
Claude finished @kyle-sexton's task in 5m 18s —— View job Security Review — PR #1275
The new commit ( No new security issues foundThe implementation is sound. Full verification below.
--no-*)
abbrev_match "dry-run" "--${x#--no-}" 2 && dry=0
abbrev_match "force-if-includes" "--${x#--no-}" 7 && if_includes=0
abbrev_match "force-with-lease" "--${x#--no-}" 7 && lease=0
((k++))
continue
;;Trace for the previously-blocked command
Post-scan:
The Abbreviated negation
The main (second) scan has no Post-scan decision is correctly placed L378–382 — the
L420 — the former per-occurrence lease check is gone. Operand boundary, allow-list, input handling — unchanged and still correct
Behavioral note (not a security issue)
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f345a1b0f7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Review found a bypass in the state model this branch introduced. It
treated `--force-with-lease=<refname>:<expect>` as superseding a bare
`--force-with-lease`, so a command carrying both was allowed outright:
git push --force-with-lease --force-with-lease=refs/heads/main:<sha> origin main other
git scopes the pinned entry to `main` alone. Every other ref in the same
push — `other` here — stays governed by the bare fallback, leasing against
its remote-tracking ref, which is exactly the form this guard exists to
stop. The pin bought safety for one ref and the guard extended it to all
of them.
The model is inverted rather than patched: only the BARE spelling is
tracked. A pinned entry says nothing about the refs it does not name, so
it can never make a fallback safe and is simply not this check's business.
That also dissolves the companion finding about pinned-only pushes — with
no bare token present there is nothing to block, and the ordering of the
two spellings stops mattering.
`--force-if-includes` still covers the fallback, and a trailing
`--no-force-with-lease` still cancels the option outright.
## Docs corrected with the behavior
The hook header and the plugin README both still said
`=<refname>:<expect>` passes unconditionally. Left alone they would have
documented the defect this commit removes, so both now state the
only-lease-spelling condition.
## Testing
`block-dangerous-git.test.sh`: 276 pass, 0 fail. Three new cases cover the
mixed bare-plus-pinned push, the reverse spelling order, and the mixed
form rescued by `--force-if-includes`. shellcheck clean; markdownlint
clean (including a table-style error this change surfaced in README.md).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8fb92af7c1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…esent
Review found the colon test was structural, not semantic. It accepted any
`=<refname>:<expect>`, including one whose <expect> is a name git resolves
when the push runs:
git push --force-with-lease=refs/heads/main:refs/remotes/origin/main origin main
A background fetch moves `refs/remotes/origin/main` first, the lease is
satisfied against the value it just fetched, and the push overwrites work
the pusher never saw. That is the bare form's hole wearing the pinned
form's syntax, and the guard returned 0 for it.
An expectation now counts as pinned only when git cannot resolve it to
something newer: an object id, or the empty string (git: "the named ref
must not already exist"). Movable names — `origin/main`, `HEAD`, `@{u}`,
any `refs/remotes/...` — leave the lease unpinned and it blocks unless
`--force-if-includes` is present.
Abbreviated object ids are accepted at four hex characters and up, since
git resolves any unambiguous prefix and a hex string cannot name a moving
target. Below four the string is too short to be a useful id and is
treated as unpinned.
## Fixtures corrected with the rule
Three existing cases used `main:abc` as a stand-in expectation. Under the
new rule that is a 3-character string, correctly unpinned, so the cases
asserted the old behavior. Widened to `abc1234`, and the 3-character form
is now its own case pinning the floor.
## Testing
`block-dangerous-git.test.sh`: 282 pass, 0 fail. Six new cases cover a
remote-tracking expectation, the `origin/main` shorthand, `HEAD`, an
abbreviated object id, a movable expectation rescued by
`--force-if-includes`, and the short-hex floor. shellcheck clean;
markdownlint clean. Hook header and README updated to state the
immutability condition rather than the old colon-only rule.
Second version collision on this branch: main shipped 0.15.0 for the skill-reference-verify guard while this branch already claimed it. Mine moves to 0.16.0; both entries stand, newest first. Also repairs the README's guard row, which git auto-merged into a broken sentence — "a push dry-run disarms, `reset --hard`, `clean` …" spliced my lease clause into main's list of other blocked operations. Split into two sentences, and the row now states the immutability condition the code actually enforces rather than the earlier colon-only rule.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b4dc425a39
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Claude finished @kyle-sexton's task in 5m 17s —— View job Code Review — PR #1275
Reviewed the changes at HEAD ( Finding 1 — IMPORTANT · Confirmed open (Codex P1,
|
| Spelling | --force-if-includes effect |
Should bypass block? |
|---|---|---|
--force-with-lease (bare) |
documented mitigation | yes |
--force-with-lease=<ref> (no colon) |
documented mitigation | yes |
--force-with-lease=<ref>:<mutable> |
explicitly a no-op (git docs) | no |
Fix — split into two state variables so the bypass is scoped correctly:
# In pre-scan reset:
lease_bare=0 # bare / =ref-only forms; --force-if-includes is a documented mitigation
lease_mutable=0 # =ref:<mutable-expect> forms; --force-if-includes is a git-documented no-op
# In --no-* arm (clears both):
if abbrev_match "force-with-lease" "--${x#--no-}" 7; then
lease_bare=0
lease_mutable=0
fi
# In * arm:
if is_lease_opt "$x"; then
if [[ "$x" != *=*:* ]]; then
lease_bare=1
elif ! lease_pins_an_immutable_expect "$x"; then
lease_mutable=1
fi
fi
# Block decision:
if ((lease_bare && !if_includes)) || ((lease_mutable)); then
block "push-lease-unsafe" ...
fiThis correctly handles all cases:
--force-with-lease=main:origin/main --force-if-includes→lease_mutable=1→ blocked ✓--force-with-lease --force-if-includes→lease_bare=1, if_includes=1→ allowed ✓--force-with-lease=main:origin/main --no-force-with-lease→ both cleared → allowed ✓
Finding 2 — Low · Design tradeoff still open (Codex P2, b4dc425 review)
{4,64} hex floor admits tag names that git resolves at push time
[[ "$expect" =~ ^[0-9a-fA-F]{4,64}$ ]]A tag named dead (four hex chars) satisfies this pattern. Git resolves --force-with-lease=main:dead against a dead tag, not an object ID, so a background fetch that updates the tag lets the lease pass while clobbering unseen work. The code comment acknowledges that "a hex string cannot name a moving target" — but this is only true when the hex string is unambiguous as an object ID, which git cannot guarantee statically for short prefixes.
The Codex suggestion of requiring the full hash length (40 for SHA-1, 64 for SHA-256) would eliminate the gap entirely but would break the common workflow of copying a 7–12 char git log OID abbreviation. This is a genuine tradeoff. The minimum of 4 in the test (abc is blocked, abc123 is allowed) does not address the Codex P2 concern: abc1 through any 4–39 char hex string is accepted and could be a tag.
Options in order of safety:
- Require ≥ 40 chars (or 64) — fully safe, breaks common abbreviated OID usage
- Require ≥ 12 chars — matches GitHub's "safe abbreviation" floor, reduces but doesn't eliminate collision risk
- Keep 4-char floor with a code comment acknowledging the limitation explicitly
Whatever is chosen, the comment currently says "a hex string cannot name a moving target" which is incorrect as stated — it should qualify this as "a sufficiently long hex string is unlikely to be a valid ref name."
Nitpick — Truncated comment at L388–390
block-dangerous-git.sh L388–390
Lines 388–390 have an incomplete sentence — a merge artifact from successive edits:
# The two spellings are INDEPENDENT, not one state. git scopes
# `=<refname>:<expect>` to that ref alone and leaves every other
# updated ref on the bare fallback, so an explicit entry never makes a
# Only a spelling that leases against something MUTABLE is tracked. An"…so an explicit entry never makes a" is a sentence fragment. The comment should be repaired to a single coherent description of what lease_bare tracks. (After Finding 1 is fixed, this whole block will need to be rewritten anyway to cover lease_bare and lease_mutable.)
Nitpick — Block message doesn't match the mutable-explicit case
The block message reads "git push --force-with-lease without an expected value…" but lease_bare is now also set for --force-with-lease=refname:<mutable-name>, which does have an explicit value. After Finding 1 is fixed, the block decision will fire separately for bare forms and mutable explicit forms — each should get a message that accurately describes why it was blocked and what the fix is (stating an immutable SHA vs. using --force-if-includes are the two distinct remedies, and only the former applies to the mutable-explicit case).
What's clean
All prior findings remain fixed:
if_includesandlease_baredeclaredlocalon L254 ✓--no-force-with-leaseclearslease_barein the pre-scan--no-*arm ✓--no-force-if-includesclearsif_includessymmetrically ✓- Allow-list contract test for
push-lease-unsafeis present ✓ plugin.jsontoggle description andblock_dangerous_git_allowdescription updated ✓--operand boundary respected in both scans ✓
…with-lease-expect
|
Claude finished @kyle-sexton's task in 4m 46s —— View job Security Review — PR #1275
The hook file at HEAD is identical to Finding 1 — IMPORTANT · Confidence: CONFIRMED
if ((lease_bare)) && ((!if_includes)); then
From git-push(1) (
So for the command: git push --force-with-lease=refs/heads/main:refs/remotes/origin/main --force-if-includes origin HEAD:mainThe pre-scan sets The root issue is that
Fix: Track these two sub-cases separately. Block the mutable-explicit-expect form unconditionally (without consulting # in the pre-scan:
if is_lease_opt "$x"; then
if [[ "$x" != *=*:* ]]; then
lease_noexpect=1 # bare or =<refname>: no expect stated
elif ! lease_pins_an_immutable_expect "$x"; then
lease_mutable_expect=1 # =<refname>:<movable>: --force-if-includes is a no-op
fi
fi
# (the --no-* arm clears both)
# post-scan decision:
if ((lease_mutable_expect)); then
block "push-lease-unsafe" ...
fi
if ((lease_noexpect)) && ((!if_includes)); then
block "push-lease-unsafe" ...
fiFinding 2 — SUGGESTION · Confidence: PLAUSIBLE4–39 hex character [[ "$expect" =~ ^[0-9a-fA-F]{4,64}$ ]]The 4-char floor is set on the basis that git accepts any unambiguous abbreviated SHA, and a hex string cannot name a moving target. But this conflates two things: a string that looks like a hex abbreviation and a string that git treats as one. Refnames may consist entirely of hex characters — a tag named git push --force-with-lease=refs/heads/main:dead origin HEAD:mainThe hook treats The correct floor is the full hash length for the repository's object format (40 for SHA-1, 64 for SHA-256). Only a full object ID is guaranteed unambiguous. The comment already acknowledges abbreviated hex ( All prior findings remain fixed
|
|
Claude finished @kyle-sexton's task in 5m 14s —— View job Code Review — PR #1275
The merge commit ( Both open Codex findings are valid and confirmed by reading the current hook and test files. Finding 1 —
|
| Option | Safety | UX impact |
|---|---|---|
| Require full 40 (SHA-1) or 64 (SHA-256) char hex only | Closes the gap completely | Users with abbreviated hashes in their lease strings must switch to full hashes or add --force-if-includes |
Accept 7–39 chars (above core.abbrev default) |
Closes the most likely collision surface | Refs named exactly 7–39 hex chars remain ambiguous |
| Document the known limitation | No code change | Gap persists with a clear note |
The most conservative safe fix is to require the full hash:
[[ "$expect" =~ ^[0-9a-fA-F]{40}$ ]] || [[ "$expect" =~ ^[0-9a-fA-F]{64}$ ]]This would also require updating tests that use short hex expects (e.g. abc123 at line 38, abc1234 at line 44, 56, 57, 62) — those would become blocked unless paired with --force-if-includes, which is git's own documented recommendation for uncertain expectations.
Everything else looks correct
All prior round findings remain addressed at HEAD:
- The
--no-force-with-leasereset (lease_bare=0, L368) is correctly placed inside--no-*arm withcontinue, preventing re-match in*). - The
--no-force-if-includesreset (if_includes=0, L366) is symmetric with the set at L386. - The
--operand boundary breaks the pre-scan (L358) and the main scan (L415), so refspec operands cannot set or clear any flag. lease_bareandif_includesare both declaredlocalat L254.- The
localdeclaration lists all per-push scan variables, consistent with prior round fixes. --force-with-lease=main:(empty expect, ref-must-not-exist assertion) correctly passes through[[ -z "$expect" ]] && return 0at L185 — still allowed ✓
…'s first-match lease Three review findings on the --force-with-lease classification: - --force-if-includes no longer rescues an explicit :<expect>. git-push(1) states the includes check is a no-op alongside --force-with-lease=<refname>: <expect>, so a movable expectation paired with it was passing unmitigated. The two unsafe kinds are now tracked separately: no-expected-value forms stay mitigable, movable explicit expectations are blocked unconditionally. - Only a full-width object id counts as immutable. gitrevisions resolves a short hex word as a ref before trying it as an object-id prefix, so a tag named 'dead' satisfied the old 4-64 hex regex while still naming a moving target. Abbreviations now fail closed; 40 (SHA-1) and 64 (SHA-256) pass. - Lease entries are tracked per ref with git's first-match rule. git's apply_cas() returns on the first entry matching the ref being updated, so a later entry for an already-pinned ref is dead text and must not drive the verdict. --no-force-with-lease clears the staked claims along with the state. README, plugin.json and the CHANGELOG entry described the old, broader contract and are corrected to what ships.
|
Claude finished @kyle-sexton's task in 3m 13s —— View job Code Review — PR #1275
The latest commit ( Codex P1 (IMPORTANT) —
|
| Command | Expected | Test line |
|---|---|---|
| pinned first, movable second (git uses first) | allowed | 78 |
| movable first, pinned second (git uses first — movable) | blocked | 79 |
| no-expect first, pinned second (first is tracking-based) | blocked | 80 |
no-expect first, pinned second, --force-if-includes |
allowed | 81 |
| different refs, one pinned one movable | blocked | 82 |
The --no-force-with-lease reset clears lease_seen along with both flags, so a restated lease after negation starts a fresh first-match window — test line 62 covers this.
Codex P2 — CHANGELOG and plugin.json documentation ✅ Fixed
CHANGELOG.md lines 22–28 now explicitly calls out the movable =<refname>:<expect> form as blocked, explains why --force-if-includes is a no-op there (citing git-push(1)), and names the passing forms (full-width OID or empty expect). The prior text that implied every =<refname>:<expect> passed is replaced.
plugin.json L44 reads: "push --force-with-lease leasing against a value git resolves at push time — either no expected value, or an expectation that is not a full-length object id". This accurately describes both blocked sub-cases. The version bump to 0.16.0 is appropriate.
Logic correctness on the new lease_ref_claimed sentinel
The sentinel [[ $'\n'"$seen" == *$'\n'"$ref"$'\n'* ]] is correct for the append-with-newline accumulation pattern (lease_seen="$lease_seen$lease_ref"$'\n'): the leading $'\n' anchors the first entry, and each subsequent entry is surrounded by newlines. Refnames cannot contain newlines (gitcli forbids it), so the delimiter is collision-safe.
All prior findings remain fixed
--no-force-if-includesresets the mitigation flag (e0bfe61) ✓--no-force-with-leaseclearslease_tracking,lease_movable, ANDlease_seen(f345a1b) ✓if_includesandlease_tracking/lease_movableare alllocalon L280 ✓- Allow-list contract test for
push-lease-unsafe(positive + cross-token negative guard) ✓ --operand boundary respected in both scans ✓- Input handling,
allowed()comma-anchor,is_lease_optabbreviation matching — all clean ✓
No further issues found
The implementation is complete and correct. The dual-flag design (lease_tracking + lease_movable), the lease_ref_claimed first-match guard, the full-width-only OID floor, and the --no-force-with-lease full-reset together close every known bypass surface in the lease family.
|
Claude finished @kyle-sexton's task in 4m 33s —— View job Security Review — PR #1275
No new security issues found
Finding (P1 from
The bypass command from the P1 finding ( Finding (P2 from
The renamed [[ "$expect" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]The prior Finding (P2 from
The new Key flows traced against the new code:
Tests L78–82 cover all four cases including the cross-ref scenario. ✓ Prior rounds all clean at HEAD
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4f05933cc9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…se pin The immutability check accepted 40- OR 64-hex in every repository, so the width that is not the repository's own slipped through as an object id when git treats it as an ordinary ref name. Verified against git 2.54.0: in a SHA-1 repository a 64-hex tag is created and `rev-parse` resolves it, while a 40-hex ref name draws git's own "ignored when you just specify 40-hex" warning and does not resolve. Under `--object-format=sha256` the pair swaps. So a movable 64-hex tag in a SHA-1 repository satisfied the lease against whatever it pointed at. Read the width once from `git rev-parse --show-object-format`, and only on the rare path that sees a hex expectation -- the guard shells out nowhere else. No repository and no git means no width, which fails closed; a push cannot succeed there either. The lease cases now run in explicit SHA-1, SHA-256, and non-repository fixtures rather than inheriting whatever directory invoked the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012nZuFpcEQqj9kpVew4imts
…with-lease-expect
|
Claude finished @kyle-sexton's task in 4m 36s —— View job Code Review — PR #1275
The implementation is correct and all prior findings (code review rounds through One low-severity finding on the new code; everything else is clean. Finding —
|
|
Claude finished @kyle-sexton's task in 5m 4s —— View job Security Review — PR #1275
Finding — SUGGESTION · Confidence: PLAUSIBLE
The code comment states: "Width of the repository the push will run in." The implementation reads: case "$(git rev-parse --show-object-format 2>/dev/null)" in
sha1) _repo_oid_width=40 ;;
sha256) _repo_oid_width=64 ;;
Exploit scenario (requires preconditions):
This is a targeted, non-trivial bypass that requires write access to the local filesystem and knowledge of both repository formats. For opportunistic use it is unrealistic; as a prompt-injection attack vector against an agent it is plausible if the attacker can pre-stage the SHA-256 fixture. Fix: In Everything else reviewed and cleanThe new
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 62a8ded13b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The probe runs in the hook's own working directory, which is not necessarily the one the push runs in: a compound `cd <elsewhere> && git push` targets a repository the probe never sees. Resolving that target would mean evaluating shell word expansion, which this guard deliberately does not do. Record the gap where the probe is defined rather than implying a guarantee it does not make. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012nZuFpcEQqj9kpVew4imts
|
Claude finished @kyle-sexton's task in 2m 39s —— View job Code Review — PR #1275
What changed
The prior text claimed the probe returns "Width of the repository the push will run in." That was inaccurate: This is the correct resolution of the SUGGESTION raised in the previous security review and the Codex P2 from the Two items from the
|
|
Claude finished @kyle-sexton's task in 3m 5s —— View job Security Review — PR #1275
No new security issues foundThe latest commit ( Status of prior open finding (SUGGESTION from
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6265943e15
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two defects in the width check, both found by review. `git -C <path>` (and `--git-dir`/`--work-tree`/`--namespace`) moves the push off the invoking directory, so `git -C <sha256-repo> push --force-with-lease=main:<40-hex>` from a SHA-1 directory was judged by the wrong repository and allowed a movable name. Those options are now replayed verbatim onto the probe rather than modelled, so git resolves the repository by its own rules, cumulative `-C` included. The walk mirrors hook::git_resolve_subcommand's two-word consumption so the two cannot desynchronize. git rejects an attached `-C<path>`, so only the separate form is collected (verified, git 2.54.0). The cache was also read through `width=$(repo_oid_width)`, and a command substitution runs the function in a subshell -- the assignment never reached the parent, so a command carrying N hex expectations spawned N git processes. On a blocking PreToolUse hook that trends toward the 60s timeout, where the guard fails open. The width is now assigned by a plain call, keyed by the option set. A shimmed `git` on PATH counts invocations: eight pinned leases must cost exactly one probe. Reverting to the substitution shape makes that case report eight, so it fails as intended. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012nZuFpcEQqj9kpVew4imts
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
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-running the job, or pushing a new commit, will retry the review. |
|
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."
Re-running the job, or pushing a new commit, will retry the review. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 20ec3bebbb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| hook::git_resolve_subcommand "$gi" "${w[@]}" || return 0 | ||
| sub=$HOOK_GIT_SUB | ||
| sub_idx=$HOOK_GIT_SUB_IDX | ||
| collect_git_locating_opts "$gi" "$sub_idx" "${w[@]}" |
There was a problem hiding this comment.
Replay wrapper directory changes in the hash probe
When Git is invoked through env -C <repo>, this collects only options following the git word, so repo_oid_width probes the hook's working directory rather than <repo>. I checked env --help, which defines -C, --chdir=DIR as changing the command's working directory; from a SHA-1 checkout, env -C <sha256-repo> git push --force-with-lease=main:<40-hex> ... made this hook return 0 even though Git runs in the SHA-256 repository, where that 40-hex spelling can resolve as a movable ref. Preserve recognized wrapper working-directory changes when constructing the probe so the unsafe lease cannot bypass the guard.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
DISCHARGED on main by PR #2100 (merge commit 1cbb4bc9). Verified independently — I did not write that fix, and I built my own reproduction rather than accept the PR body's wrapper matrix.
Every case run against BOTH trees: PRE = 1cbb4bc9^, POST = origin/main (50e7a33b). Exit 2 = BLOCKED, 0 = ALLOWED. Real SHA-1 and SHA-256 fixture repositories (41- and 65-character OIDs confirmed).
The filed form:
env -C <sha256-repo> git push --force-with-lease=main:<40-hex>
PRE=ALLOWED POST=BLOCKED
Controls — unchanged by the fix, which is what makes the rest worth anything:
no wrapper, 40-hex in sha1 repo (legit OID) PRE=ALLOWED POST=ALLOWED
no wrapper, 40-hex in sha256 repo (wrong width) PRE=BLOCKED POST=BLOCKED
no wrapper, 64-hex in sha256 repo (legit OID) PRE=ALLOWED POST=ALLOWED
plain --force PRE=BLOCKED POST=BLOCKED
Wrapper forms beyond those the PR reported — all fixed:
env --chdir=<dir> PRE=ALLOWED POST=BLOCKED
env --chdir <dir> PRE=ALLOWED POST=BLOCKED
env -C<dir> (bundled) PRE=ALLOWED POST=BLOCKED
nice env -C <dir> (nested) PRE=ALLOWED POST=BLOCKED
env -C <dir> env -C <dir> PRE=ALLOWED POST=BLOCKED
env -u FOO -C <dir> PRE=ALLOWED POST=BLOCKED
git -C <dir> (git's own) PRE=BLOCKED POST=BLOCKED
env -C <dir> git -C <same> PRE=BLOCKED POST=BLOCKED
Reverse direction — the fix relocates the probe rather than just blocking more:
env -C <sha1> from sha256 cwd, 64-hex PRE=ALLOWED POST=BLOCKED
env -C <sha1> from sha256 cwd, 40-hex PRE=BLOCKED POST=ALLOWED (a genuine OID there)
That last row is the one I would have missed if I had only tried to break the guard. A lease that really is an object id in the repository git actually runs in is correctly allowed again.
One case looked like a surviving bypass and was not. env FOO=1 -C <dir> git push … is ALLOWED on both trees. Before reporting it, I checked what GNU env does with an option that follows a NAME=VALUE assignment:
$ env FOO=1 -C target pwd
env: '-C': No such file or directory (rc=127)
env (GNU coreutils) 8.32 stops option parsing at the first assignment, so -C becomes the command name and nothing executes — git never runs and no push happens. Allowing an inert command is correct, not a hole. Recording it because the form looks live, and the next person to try it deserves the answer without re-deriving it.
There was a problem hiding this comment.
Correcting my own comment above: I resolved this thread and should not have. Reopened, and tracked at #2124.
My verification ran 16 cases and all 16 behaved. That was not enough. The session that wrote the fix then handed me a list of forms neither of us had tried, and several of them bypass the guard on origin/main today.
Every "ALLOWED" below was additionally confirmed to actually execute git in the SHA-256 repository, by substituting rev-parse --show-toplevel for the push. That check is what separates a hole from an inert command, and it is what I used above to correctly discard env FOO=1 -C.
1. The payload's cwd is never read — and this one needs no wrapper at all.
payload .cwd = sha256, hook process cwd = sha1 -> ALLOWED <-- bypass
payload .cwd = sha256, hook process cwd = sha256 -> BLOCKED (control)
payload .cwd = sha1, hook process cwd = sha1 -> ALLOWED (control, correct)
repo_oid_width probes the hook process's working directory. The sibling guard in this same plugin does not:
block-noncanonical-commit.sh:118 HOOK_CWD=$(... jq -r '.cwd // empty' ...)
block-noncanonical-commit.sh:883 HOOK_EFFECTIVE_BASE="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
block-dangerous-git.sh (no match for cwd)
2. env -S / --split-string splices the chdir past the parser — squarely inside this finding's scope, since it is env performing a -C:
env -S '-C <sha256> git push --force-with-lease=main:<40-hex> origin main' ALLOWED (executes)
env --split-string='-C <sha256> git push --force-with-lease=... ' ALLOWED (executes)
3. A shell cd reaches the same harm by another route — arguably outside the wrapper-replay design, but recorded because #1 already means the no-cd case is unsound:
sh -c "cd <sha256> && git push --force-with-lease=main:<40-hex> origin main" ALLOWED (executes)
(cd <sha256> && git push --force-with-lease=main:<40-hex> origin main) ALLOWED (executes)
What the fix genuinely did buy, and why it should not be reverted: timeout 60 env -C, nohup env -C, command env -C, bash -c 'env -C …' all BLOCK, as do all sixteen round-one cases. xargs -I{} env -C reads ALLOWED but did not execute in the harness, so I am not counting it.
On my own error. Sixteen passing cases and four controls felt like enough, and the wrong lesson to draw is "run more cases." What actually caught this was someone who knew the code handing over the forms they had not tried — the negative space of their own testing. My round one inherited the blind spot of the fix it was checking, which is precisely what an independent verifier is supposed to not do. Resolving on it was the mistake; a finding this class should have stayed open until an attack round found nothing new, not until my first round found nothing.
There was a problem hiding this comment.
DO NOT RESOLVE THIS THREAD. The bypass is live on origin/main today — shipped code, no wrapper needed, fails open. Tracked at #2124; it should be reopened again if anything clears it before that lands.
Flagging this explicitly because two PRs on this sweep (#2100 and #2115) have merged with their review threads resolved by an actor that neither session working them can account for. resolvedBy is a single shared account so it cannot discriminate, and GraphQL exposes no resolvedAt. Until that is understood, an open thread is not a reliable hold, so the hold is stated here in the text as well.
… in the hook (#272) ## Summary The permission floor denied every `--force-with-lease` spelling — including the forms that are actually safe. This removes those four patterns and moves enforcement to the one place the distinction can be expressed. ## Why `deny` cannot do this job Claude Code's Bash rules are whole-string globs with `*` as the only metacharacter, and precedence is fixed ([permissions](https://code.claude.com/docs/en/permissions)): > Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity doesn't change the order. > A broad deny rule like `Bash(aws *)` blocks every matching call, including calls that also match a narrower allow rule like `Bash(aws s3 ls)`, so a deny rule can't carry allowlist exceptions. No negation, no exceptions. So a `deny` here is all-or-nothing. ## Why that matters — the forms are not equivalent [git-push(1)](https://git-scm.com/docs/git-push), "A general note on safety": > supplying this option without an expected value, i.e. as `--force-with-lease` or `--force-with-lease=<refname>` interacts very badly with anything that implicitly runs `git fetch` … this is **trivially defeated if some background process is updating refs in the background**. Only `--force-with-lease=<refname>:<expect>` states the expectation, and it is the one form git does not mark experimental. A single glob cannot deny the unsafe spellings and permit the safe one — so the floor denied all of them, which is why a correctly-formed safe push was blocked in practice. ## What replaces it The `guardrails` plugin's `block-dangerous-git` PreToolUse hook, which parses the argv and can therefore make the distinction: - Blocks any lease that resolves against something **movable at push time** — bare, `=<refname>`, or an `=<refname>:<expect>` whose `<expect>` is a name like `origin/main`, `HEAD`, or `refs/remotes/...`. - Permits an expectation git cannot resolve to something newer: an object id, or the empty string (asserting the ref must not exist). - Honors `--force-if-includes` (git 2.30+) as git's documented mitigation for the unpinned forms. - Tracks the last-wins negations git documents as `--[no-]force-with-lease` and `--[no-]force-if-includes`. Claude Code's own docs name a PreToolUse hook as the mechanism for exactly what globs cannot express. ## Ordering — this lands second, deliberately Removing the deny on its own would have been a **net widening**. The hook permitted every lease form until melodic-software/claude-code-plugins#1275, so dropping the blunt rule first would have exposed the unsafe spellings with nothing catching them. The hook hardens first; the blunt rule comes out after. ## README "Force/destructive spellings stay covered by `deny`, which always wins" is corrected rather than deleted — it still holds for every other force spelling. The carve-out is recorded beneath it with the reason and both upstream citations, so the next reader does not re-add the patterns. ## Verification - `claude-permissions.json` validates; the four removed entries are the only change to it (2 Bash, 2 PowerShell mirrors). - Pre-commit gates green: biome, editorconfig, gitleaks, typos, markdownlint. ## Related - melodic-software/claude-code-plugins#1275 — the hook that makes this safe. **Merge that first.** - #267 — in flight on the same component; it trims the **allow** floor while this touches **deny**, so they should auto-merge. Its README rewrite touches the same paragraph, so whichever lands second should confirm the carve-out survived. No linked issue: this is the second half of the melodic-software/claude-code-plugins#1275 change, not a separately tracked defect. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… rescope skill-reference-verify reconstruction (#2100) No linked issue ## Summary Discharges six stranded bot review threads against the `guardrails` plugin, all filed on already-merged PRs. One is a defeated security guard, the rest are `skill-reference-verify` correctness and timeout-budget defects. Four further threads raised on this PR are also addressed below. ## Fix **`block-dangerous-git` — the hash-width probe ignored a wrapper's chdir (thread on #1275).** A `--force-with-lease` expectation is judged immutable only when it is an object id of the hash width of the repository the push will run in. `collect_git_locating_opts` reads only the slice between the git word and the subcommand — as it must, since that walk cannot know which of `env`'s or `sudo`'s options take a value — so a wrapper's relocation was invisible to it. `env -C <sha256-repo> git push --force-with-lease=main:<40-hex>` therefore probed the invoking SHA-1 directory, read the 40-hex word as an object id, and allowed the push; where git actually runs that word is an ordinary movable ref name, which is exactly the hole `--force-with-lease` exists to close. `hook::git_resolve_index` already records the relocation in `HOOK_GIT_RESOLVED_WRAPPER_DIRS` — the only parser that tells a real `env -C <dir>` from the `-C` in `env -u -C git`, which moves nothing — and the probe now replays those directories as leading `-C` words so they compose ahead of git's own under git's rules rather than being modelled. This mirrors the migration `848df9e9` (#1785) made in `block-noncanonical-commit`. **`skill-reference-verify` — partial-Edit reconstruction (threads on #1319 and #1466, one span).** The old shape located the hunk by line and then filtered the whole physical line by word token. Three defects, all that filter: an untouched broken reference sharing a line with the hunk was readmitted by any word it happened to share; an Edit replacing fewer than four lowercase characters produced no token at all, so every short-substring edit went uncovered; and locating spent two full-file `grep` processes per hunk line, which a large Edit turned into the hook's 30s timeout. Reconstruction now keeps only the inline-code spans whose extent OVERLAPS the located anchor. The occurrence-uniqueness gate is unchanged. **`skill-reference-verify` — the cost model behind the timeout fix was wrong, twice.** Removing the subprocesses left a per-line RESCAN, so the hunk is now located WHOLE — one scan for the whole edit, producing the same span set, since a line anchor's extent is the text the edit wrote on that line and the whole hunk's extent is the union of exactly those. Measuring the scan itself then contradicted the bound placed on it: one scan is QUADRATIC in file size, not linear, because bash's `%%` pattern strip walks the string rather than indexing it. The previous 4 MiB file cap therefore allowed a single scan of roughly eighteen minutes — the worst case had been moved off the per-line loop, not bounded. Both caps are now set from the measured curve. **`skill-reference-verify` — manifest-declared skill paths (thread on #1319).** Resolution hard-coded `plugins/<plugin>/skills/`. Per the [Plugins reference](https://code.claude.com/docs/en/plugins-reference) (fetched 2026-08-09), `skills` is a `string|array` whose paths ADD to the default `skills/` scan, a path may point straight at a directory holding `SKILL.md`, and a root `SKILL.md` with no `skills/` and no `skills` key auto-loads as a single-skill plugin. All three now resolve. The documented marketplace-root exception is deliberately not modelled and is recorded as such at the call site — leaving it out only ever suppresses an advisory, never invents one. The advisory's own text carried the same hard-coded assumption and now lists the directories the search actually covered. ## Verification **Security defect, reproduced before and after** against the same fixture tree (SHA-1 and SHA-256 repos), hook cwd = the SHA-1 repo unless noted. `origin/main`'s `block-dangerous-git.sh` vs this branch's: | case | pre-fix | post-fix | want | | :-- | :-- | :-- | :-- | | `env -C <sha256> git push --force-with-lease=main:<40-hex>` | **ALLOWED** | BLOCKED | BLOCKED | | `env -C <sha256> git push --force-with-lease=main:<64-hex>` | **BLOCKED** | ALLOWED | ALLOWED | | `env -C <sha1> git push …:<64-hex>` (cwd = sha256) | **ALLOWED** | BLOCKED | BLOCKED | | `env --chdir=<sha256> git push …:<40-hex>` | **ALLOWED** | BLOCKED | BLOCKED | | `sudo -D <sha256> git push …:<40-hex>` | **ALLOWED** | BLOCKED | BLOCKED | | `sudo --chdir=<sha256> git push …:<40-hex>` | **ALLOWED** | BLOCKED | BLOCKED | | `bash -c 'env -C <sha256> git push …:<40-hex>'` | **ALLOWED** | BLOCKED | BLOCKED | | `env -C <parent> git -C repo-sha256 …:<64-hex>` | **BLOCKED** | ALLOWED | ALLOWED | | `env -u -C git push …:<40-hex>` (`-C` is `-u`'s operand) | ALLOWED | ALLOWED | ALLOWED | The last row is the control that keeps the fix honest: an option that only looks like a chdir still moves nothing, so the guard did not simply get stricter. Two rows flip BLOCKED → ALLOWED, which a fail-closed regression could not produce. **Scan cost, measured rather than assumed.** One `anchor_offsets` scan, isolated, Windows/Git Bash, quiescent, best of three: | file size | 32 KiB | 64 KiB | 96 KiB | 128 KiB | 192 KiB | 256 KiB | | :-- | :-- | :-- | :-- | :-- | :-- | :-- | | one scan | 0.07s | 0.24s | 0.53s | 1.07s | 2.18s | 3.94s | That is ~0.065s × (KiB/32)² — quadratic. Those figures are a FLOOR, not the cost: they time an anchor matching near the end, so one strip walks the file and the second is free, while a no-match strip walks it twice (2.31s at 200 KiB) and the whole-hunk probe pays a scan before the fallback runs at all. So the two bounds are calibrated end to end, not from the table: `RECONSTRUCT_MAX_CHARS` is 128 KiB, and the fallback's anchor cap is `RECONSTRUCT_FALLBACK_SCAN_BUDGET / (KiB)²` — 58 anchors at 32 KiB, 14 at 64, 3 at 128. Above the file cap the direct hunk scan is untouched, so a complete reference is still reported and only partial-edit recovery stops. **End-to-end, the shape the defect actually lived in** (a hunk of distinct span-free lines, so the span cap never binds and every anchor would rescan). `origin/main` vs this branch, same fixture: | hunk | file size | `origin/main` | this branch | | :-- | :-- | :-- | :-- | | 1 line | <1 KiB | 10.5s | 0.8s | | 100 lines | ~4 KiB | 135.8s | — | | 500 lines | ~19 KiB | 778.8s | — | | 1000 lines | ~38 KiB | (not run) | 1.0s | Baseline per-invocation overhead on this host is 0.8–1s quiescent, so the branch numbers are the scan, not the harness. An earlier revision of this PR reported far flatter pre-fix numbers; that benchmark used hunk lines carrying inline code spans, which trip `RECONSTRUCT_MAX_SPANS` and stop the loop after 40 anchors — it measured the capped path, not the defect. The table above is the corrected measurement. A 4000-line row from that revision is dropped rather than restated: at ~156 KiB it now exceeds the file cap, so it would time the skip path, not reconstruction. **Why the scale test asserts behavior instead of wall time.** The new large-file fallback case pins the cap from both sides — a reference inside the anchor cap is still reported, one past it is not — rather than timing it. On this host the same fixture read 21s loaded and a smaller one 23s, against an isolated scan of ~1s at that size; a timing assertion that noisy fails on load and passes on a regression that happens to run on a quiet box. The scan cost is measured directly instead, in the constants' docblock. **Gates run from the worktree root, all green:** `shellcheck -x` on the four changed shell files; `markdownlint-cli2` on the changelog; `check-changelog-parity.sh --check`, `--check-bump origin/main`, `--check-order`; `check-shell-portability.sh origin/main`; `sync-hook-utils.sh --check` and `--check-bump`; `check-cross-plugin-source-drift.sh --check`; `validate-plugins.sh`; `check-changed-skills.sh origin/main`. Contract suites: `block-dangerous-git.test.sh` 341 pass / 0 fail; `skill-reference-verify.test.sh` 96 pass / 0 fail (see also the CI `plugin-gate` job, which runs both on Linux). **Four threads raised on this PR.** `Xp-3r` (quadratic rescan) and `XqJ0e` (nothing bounds the anchor count) are both discharged by the whole-hunk locate plus `RECONSTRUCT_FALLBACK_SCAN_BUDGET`; the suite fixture the first was measured against at 35s now runs in 0s. `XqJ1e` (the advisory hard-coded `plugins/<x>/skills/`) is fixed and asserted on its full rendering, not a prefix. `XrcOx` (no test combines a large file with the fallback path) is the case described above. **Not fixed here, flagged instead:** `block-convention-violation.sh`'s `effective_dir` (`:186-201`) scans *every* word for `-C`, with no `[git, subcommand)` slice and no wrapper replay — the pre-`848df9e9` shape, failing the opposite direction from the one fixed here. It accepts a `-C` that moves nothing (`env -u -C git`) and a `-C` after the subcommand (`git commit -C HEAD`, reuse-message), so `effective_dir` can name the wrong repository. Different defect class, needs its own tests; not widened into this PR. ## Related - Review thread on #1275 — `block-dangerous-git` wrapper-chdir hash probe (the security defect) - Review threads on #1319 and #1466 — `skill-reference-verify` reconstruction and manifest skill paths - #1785 (`848df9e9`) — the wrapper-chdir parser in the shared lib this fix consumes - #1466 (`527dcd85`) — already landed the hunk-line anchoring; a further thread on it needed no new fix - #1432 (`a2d98f8a`) — the sibling `stale-path-verify` fix the reconstruction docblock cites --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s from every git guard (#2147) ## What Two live holes on `origin/main`. One is specific to `block-dangerous-git`'s lease-width probe; the other is in the **shared argv resolver** and reached every guard in every plugin. `hook-utils.sh` exists in **17 places** — `lib/hook-utils.sh` plus a synced copy in each of 16 plugins — and all 17 were stale. An independent adversary confirmed the resolver hole is not lease-specific: behind `env -S`, `block-no-verify` allowed `git commit --no-verify` and `block-dangerous-git` allowed `git reset --hard`. All 17 copies are patched here. It also proved the lease hole live rather than theoretical: in a SHA-256 repository carrying a ref literally named `0123456789abcdef0123456789abcdef01234567`, the cleared force push **clobbered the remote branch with unrelated orphan history**, rc=0, with `rev-parse` captured before and after. The guard allows `--force-with-lease=<ref>:<expect>` only when `<expect>` is a **full-width object id for that repository's hash format**, because git cannot resolve one to something newer at push time. Hex of the *other* width is an ordinary, movable ref name there — a 40-hex lease in a SHA-256 repository is exactly the hole `--force-with-lease` exists to close. **Route 1 — the payload's `cwd` was never read.** The probe ran `git rev-parse --show-object-format` from the **hook process's** directory. Claude Code launches hooks from the session root and runs the Bash tool wherever the session stands, so the two differ routinely. No wrapper and no `cd` were required: a plain `git push` was enough. **Route 2 — `env -S` / `--split-string` spliced options past the parser.** `-S` exists so a shebang line can pass OPTIONS to env (`#!/usr/bin/env -S -i prog`), so its split words are env's own arguments. `hook::git_resolve_index` spliced them back into its scan but resumed at the **command dispatcher**, which read a leading option in the split string as the command NAME and abandoned the segment. `env -S '-C <dir> git push --force'` resolved to *no git at all* — so this was not only a lease-width hole; a bare `env -S '-v git push --force'` also went unexamined. ## The fix - The payload's `.cwd` is read and replayed as a **leading `-C`**, ahead of `HOOK_GIT_RESOLVED_WRAPPER_DIRS`, which already precede git's own options. That reproduces execution order end to end and composes under git's own rules — a later `-C` composes onto an earlier one, an absolute one wins — so it is the same mechanism the wrapper replay already ships, with a first term added. Not a `cd`: a `cd` would move the hook process and leak across the recursive alias walk. - The base chain is `HOOK_EFFECTIVE_BASE` → `HOOK_CWD` → `CLAUDE_PROJECT_DIR` → `.`, adopted verbatim from `block-noncanonical-commit` rather than invented a second time. `HOOK_EFFECTIVE_BASE` is not decoration: a `!` shell alias runs its body as a fresh command in the relocated repository, so the base is relocated for that reparse and save/restored around it. This guard recurses through `!` aliases the same way the sibling does. - `hook::git_resolve_index` resumes inside **env's own option loop** after an `-S` splice. That also keeps env's single chdir slot last-wins across the splice (`env -C a -S '-C b git …'` lands in `b`), matching GNU env. - The `repo_oid_width` known-gap docblock is restated at its real width (see below). ## Behaviour change, stated so it is not read as a regression **A RELATIVE `-C` / `--git-dir` / `--work-tree` / `--namespace` now rebases onto the payload cwd** instead of the hook process's directory. That is the correct resolution — a relative path written in a tool call means relative to where that call runs — and it is a change only in the sense that the previous answer was measured from the wrong origin. An **absolute** one is unaffected. Cases 4b/4c below pin it, and there is a test for the absolute form staying put. One further consequence of adopting the sibling's chain: with **no `.cwd` in the payload at all**, `CLAUDE_PROJECT_DIR` is preferred over the hook process's directory. A real PreToolUse payload always carries `cwd`, and this matches `block-noncanonical-commit`; case 5b pins it either way. ## Verification Every row was run against **both trees from one script** — PRE is `origin/main` extracted verbatim, POST is this branch — over real SHA-1 and SHA-256 fixture repositories. Exit 2 = BLOCKED, 0 = ALLOWED. Two independent liveness columns, because a table can be inert in two different ways: - **pPOST** — the width the hook's own probe resolved, scraped from `bash -x` (`_repo_oid_width=NN`). The guard fails closed on width `0`, so a BLOCK from `0` is fail-closed noise, not the fix working. Every POST=BLOCKED row below resolved a real width. - **EXEC** — what the command's git *actually does*: the push replaced by `rev-parse --show-object-format`, the exact wrapper form run for real from the payload cwd. A form that never reaches git is not a bypass. | case | PRE | POST | pPRE | pPOST | EXEC | what it pins | |---|---|---|---|---|---|---| | 1a | 0 | **2** | 40 | 64 | sha256 | payload cwd = SHA-256 repo, hook process in SHA-1 one, 40-hex lease — **the bypass** | | 1b | 2 | 2 | 64 | 64 | sha256 | control: both directories agree; fixture discriminates | | 1c | **2** | **0** | 64 | 40 | sha1 | **opposite direction** — payload cwd = SHA-1 repo, 40-hex is a genuine object id where it runs | | 2a | 0 | **2** | – | 64 | sha256 | `env -S '-C <sha256> git …'` | | 2b | 0 | **2** | – | 64 | sha256 | `env --split-string='-C <sha256> git …'` | | 2c | 0 | **2** | – | – | sha1 | `env -S '-v git push --force'` — a plain force push hidden behind a leading option | | 2d | 2 | 2 | – | – | sha1 | no-regression: `env -S 'git push --force'` (no leading option) was and stays blocked | | 2e | 0 | **2** | – | 64 | sha256 | `env -C <sha1> -S '-C <sha256> …'` — one slot, last wins | | 2f | 0 | 0 | – | 40 | sha1 | `env -C <sha256> -S '-C <sha1> …'` — last wins the other way (semantics pin, paired with 2e) | | 3a | 0 | **2** | 40 | 64 | sha256 | `git -C <sha256> -c alias.y='!git <lease>' y` — the `!` body runs in the relocated repo | | 3b | **2** | **0** | 64 | 40 | sha1 | opposite direction through the same `!` path | | 4a | 2 | 2 | 64 | 64 | sha256 | relative `git -C` with both directories agreeing — unchanged | | 4b | **2** | **0** | 0 | 40 | sha1 | relative `git -C` resolves against the payload cwd (PRE probed width `0` — it was resolving nothing) | | 4c | **2** | **0** | 0 | 40 | sha1 | relative `--git-dir` rebases the same way — the disclosed change | | 5a | 2 | 2 | 64 | 64 | sha256 | no `.cwd`, no `CLAUDE_PROJECT_DIR` → `.` (pre-fix behaviour preserved) | | 5b | 2 | **0** | 64 | 40 | sha256 | no `.cwd` → `CLAUDE_PROJECT_DIR` (chain rung 2; EXEC differs because the divergence is synthetic) | | 6a | 0 | 0 | – | – | *(none)* | inert-form control: `env FOO=1 -C <dir> git …` — coreutils stops at `NAME=VALUE`, rc 127, git never runs, so there is nothing to block | `–` in a probe column means no probe ran (no lease expectation on that row, or no git resolved). **Every case that claims a fix carries a control that FAILS against `origin/main`**: 1a, 2a, 2b, 2c, 2e, 3a (PRE allowed, POST blocked) and 1c, 3b, 4b, 4c, 5b (PRE blocked, POST allowed). 1b, 2d, 4a, 5a and 6a answer the same on both trees by design and are labelled as controls, not as evidence. ### Regression coverage added - `plugins/guardrails/hooks/block-dangerous-git.test.sh` — 341 → **363 pass / 0 fail**. `run_in` now states the payload `cwd` alongside the process directory (without it the suite silently measures `CLAUDE_PROJECT_DIR`, i.e. the host repository, in any session that exports it); `run_split` and `run_nocwd` cover the divergent and degraded payload shapes. - `lib/hook-utils.test.sh` — **164 pass / 0 fail**, with resolver-level `env -S` cases including the attached-operand spelling, the last-wins slot across a splice, and a self-referential `env -S '-S -S'` termination check. ## Not in scope, deliberately - **A shell `cd` relocation** (`cd X && git push …`, `(cd X && …)`, `sh -c 'cd X && …'`). Resolving it means evaluating arbitrary shell word expansion, which this guard deliberately does not do. It remains a documented gap — and the docblock describing it is corrected in this PR, because it listed a "compound `cd`" as one of three required conjuncts when at the time **none** of them were required. A documented gap that reads narrower than it is, is how this one survived review. - **A persisted (config-file) alias carrying the lease** (`git config alias.yolo 'push --force-with-lease=…'` then `env -C <dir> git yolo`). This guard resolves inline `-c` aliases only; persisted-alias resolution is a separate capability `block-noncanonical-commit` has and this one does not. Flagged in #2124 for triage, not asserted there as a bypass. - **An explicit `--git-dir` / `--work-tree` inherited by a `!` shell-alias body.** git EXPORTS them into the body's environment (verified on git 2.54.0 — the body prints `sha256` from a SHA-1 directory and sees `GIT_DIR` set), so the body works in a repository the composed directory does not name. `effective_dir` composes `-C` only, so the lease is judged against the base. **Reproduced against BOTH `origin/main` and this branch (PRE=0, POST=0, EXEC=sha256)** — it is pre-existing and of the same family, not introduced here, and closing it means replaying the inherited globals rather than a directory: a larger mechanism than the base chain #2124's design section scopes this change to. Now documented in the `effective_dir` docblock and the CHANGELOG rather than left implicit, on the same principle that motivated the docblock correction above. - **The claimed relative-`git -C` misprobe that does not reproduce.** #2124 records it as tested against `origin/main` and not reproducing — the relative form resolves against the hook process's cwd *and* the command's cwd, which are the same directory in that scenario. It is subsumed by route 1, not separate, and no separate change was made for it. ## Two findings from adversarial review, folded in - **A false git semantic in the diff's own prose.** It said a `!` shell-alias body "starts in THIS segment's relocated directory". Measured: a `!` body runs from the repository **top level**, not the caller's directory (`alias.wd='!pwd'` from `<repo>/sub` prints `<repo>`). The conclusion is unchanged — an object format is a property of the repository, and the composed directory and its top level are the same repository — but the claim is corrected rather than left load-bearing on a wrong premise. - **An unexplained asymmetry that turned out to be correct.** `effective_dir` composes only `-C` while `collect_git_locating_opts` also replays `--git-dir`/`--work-tree`/`--namespace`. The reviewer expected a bug and found it right: only `-C` relocates a `!` body (`git -C <other> -c alias.wd='!pwd' wd` moves, `git --git-dir=<other> …` does not). A comment now says why, so the next reader does not file it as the bug this one nearly did. ## The known gap's primary symptom is a FALSE BLOCK, not a bypass Worth stating plainly because reviewers reasonably read "known gap" as "hole": with a shell `cd`, the probe measures a base that is frequently not a repository at all, answers width `0`, and fails closed. So ``` cd <repo> && git push --force-with-lease=main:<literal full-width sha> origin main -> BLOCKED ``` — the exact form the guard's own block message prescribes — is denied from a session root that is not itself a repository. Fail-closed is the right default for an unresolvable base, and this is not a regression (it behaves the same on `origin/main`), but the docblock now records the false block as the symptom to measure, because a guard that refuses correct usage it just recommended teaches people to route around it. Conversely, the fix **removes** a false block as well as a bypass: the inverse-skew row (hook process in SHA-256, payload cwd in SHA-1, 40-hex lease) goes DENY → ALLOW, which is correct because that word is a genuine object id where the command runs. ## What was NOT tested — carried forward rather than buried - **No PowerShell payloads were used by the adversarial pass at all.** The guard matches `Bash|PowerShell`, so the entire lease-width and `env -S` surface is unverified on that arm by the adversary. This branch adds PowerShell cases of its own (payload-cwd pinning plus a missing-`cwd` tool-name case) but they do not cover the `env -S` surface. - **`hook::require_jq` was not read**, and this guard now requests three payload fields instead of two. The behaviour when jq is absent — the guard skipping entirely — is a separate, already-filed concern, not something this branch changes. - The abbreviated-hex rows (7 and 12 hex) were examined and deliberately **not** "fixed": ambiguity with a short ref name is real, and blocking them is correct. - `+refspec` force detection held on every form tried; `-S` termination held across six degenerate operands under a 25 s timeout. - The 13/0 PRE-vs-POST discrimination split reproduced twice, but the final uncontended full pass was still running when the adversary reported. ## Blast radius `lib/hook-utils.sh` is a synced library: `scripts/sync-hook-utils.sh` distributes it to every plugin carrying `hooks/hook-utils.sh` — 16 plugin copies plus the `lib/` source, 17 files, all stale on `origin/main` — and each plugin must bump so consumers receive the change. All 16 carrying plugins are bumped with a CHANGELOG entry; `guardrails` takes a minor bump (0.23.1 → 0.24.0) for the behaviour change above, the other 15 take a patch. `scripts/sync-hook-utils.sh --check-bump origin/main` and `scripts/check-changelog-parity.sh --check-bump origin/main` both pass, as do `--check-order`, `check-silent-skips.sh` and `check-cross-plugin-source-drift.sh --check`. Closes #2124 ## Related - #1275 — where `PRRT_kwDOTCGFQM6TzGBZ` was filed - #2100 — the partial fix this completes, and the round-one verification that wrongly closed the thread - #1938 — the stranded post-merge review-findings sweep - #2120 — the previous `lib/hook-utils.sh` change, whose 15-plugin fan-out this one mirrors --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

No linked issue
Summary
block-dangerous-gittreated every--force-with-leasespelling as safe force. Two of them are not, by git's own account, and the guard let them through while blocking--forcefor the same underlying hazard.Fix
--force-with-leaseand--force-with-lease=<refname>state no expected value, so git leases against the remote-tracking ref. From git-push(1), "A general note on safety":So the lease can be satisfied by a ref some other process fetched, and the push clobbers work the pusher never saw — the failure mode
--forcehas, reached by a slower road. Git also marks every form other than=<refname>:<expect>experimental.Those two no-expected-value forms are now blocked under a new
push-lease-unsafetoken, unless--force-if-includes(git 2.30+) is present — git's documented mitigation for exactly these forms, which it declares a no-op alongside an explicit:<expect>.What still passes
--force-with-lease=<refname>:<expect>, including an empty<expect>(asserts the ref must not exist — still explicit).--force-if-includes.Detection detail
Unique-prefix abbreviations are handled.
--force,--force-with-leaseand--force-if-includesshare the--forceprefix, so--force-wand--force-iare the shortest spellings git accepts, and both match. A shorter--forcis ambiguous and git rejects it outright, which is why the exact--forcearm needs no abbreviation handling. After--, words are operands rather than flags, so a literal--force-if-includesrefspec does not disarm the check.Why the hook, and not the permission deny-list
This started from the opposite direction: a
--force-with-leasepush was denied by theclaude-permissionsfloor, and the obvious fix looked like removing that deny.Research against the permissions docs killed that:
Bash rules are whole-string globs with
*as the only metacharacter — no negation. So "deny the unsafe lease forms, allow=<ref>:<expect>" is not expressible in the permission language, and the docs name a PreToolUse hook as the mechanism for what globs cannot express. This is that hook.The blunt deny in
melodic-software/standardsis therefore doing a job the permission layer cannot do precisely. Removing it before this landed would have been a net widening — it would have exposed the unsafe bare form, whichguardrailspermitted. With this merged, that deny can be dropped and the policy becomes: safe form allowed, unsafe forms blocked, both enforced where the distinction is actually expressible.Testing
block-dangerous-git.test.sh— 261 pass, 0 fail. 14 new cases: bare,=<refname>,=<refname>:<expect>, empty<expect>, both abbreviations,--force-if-includesalone and paired, dry-run, and the--operand boundary. Three existing cases asserted the old permissive behavior and were updated to the new contract; one PowerShell case likewise, plus a new PowerShell case for the passing form.shellcheck -xclean at the repo ruleset.markdownlint-cli2clean.plugin.jsonvalidates.Related
melodic-software/standards#267— in flight on the sameclaude-permissionscomponent (it trims the allow floor; this affects deny policy). Its README states "Force/destructive spellings stay covered bydeny, which always wins" — the follow-up that drops the lease deny will need to update that sentence.--force-with-leasedeny patterns (BashandPowerShellmirrors) from theclaude-permissionscomponent now that the precise check exists here.