fix(hooks): scope trunk protection to the project's own repository - #539
Conversation
block-push-to-main.sh blocked any push whose destination ref resolved to the trunk, without first asking which repository the push targets. Trunk protection exists to route changes to THIS project through a PR, but the guard fired on pushes to unrelated repositories too, where there is no PR flow to route through and nothing to protect. The case that surfaced it: publishing a design doc to a GitLab project wiki. A wiki lives in a separate `<project>.wiki.git` repository whose default — and only — branch is `main`. Every wiki update tripped the guard, so wiki pages could not be pushed at all. The same false positive hits a sibling checkout or a vendored dependency that happens to sit on its trunk. Resolve the target repository with the existing resolve_git_command_cwd helper (it already understands `git -C <path> push` and `cd <path> && git push`), then compare git-common-dir the way block-commit-outside-worktree.sh already does and exit 0 when the push lands in a different repository. No new helper: this reuses what lib.sh exports and mirrors the identity check the sibling guard was already performing, so the two hooks now agree on what "this repo" means. Comparing git-common-dir rather than git-dir keeps a linked worktree of this project counted as this project, so the scoping cannot be used as an escape hatch. Resolution uncertainty falls back to the inherited cwd, leaving an unparsable command checked rather than waved through. Extends tests/unit/test-block-push-regex.sh with 4 cases (2 reproducing the false positive, 2 pinning the escape-hatch boundary): TC-BP-12 push another repo's main via `git -C` -> allow TC-BP-13 push a `<project>.wiki.git` main via `cd &&` -> allow TC-BP-14 push this repo's main via `git -C <self>` -> still block TC-BP-15 push this repo's main from a linked worktree -> still block 15/15 pass; the two new allow-cases fail on the unmodified hook (13 pass / 2 fail), and TC-BP-14/15 already passed before the change, confirming the scoping narrows nothing it should still catch. Bare `git push` was verified separately across all three shapes — same repo on a feature branch (allow), same repo on trunk (block), and a different repo sitting on trunk (allow) — since parse_push_target_refspec reads the current branch and only runs on the same-repo path. test-block-commit-outside-worktree.sh, test-install-git-pre-push.sh and test-chp-commit-file.sh still pass (they share lib.sh); shellcheck -x is clean.
Layer-1 trunk protection asked the wrong question. It compared local `git-common-dir` to decide whether a push was in scope, but what it protects is the project's REMOTE trunk — so the question is "where does this push land?", not "which local checkout issued it". Local identity is only a proxy for the destination, and a false one in both directions: - False negative: a second independent clone of this project has a different common-dir but pushes to this project's own trunk, so `git -C <2nd-clone> push origin main` was ALLOWED. Layers 2/3 are no excuse — Layer 2 installs per worktree/clone, so a fresh clone likely has no pre-push hook at all. - False positive (the motivating wiki bug, only half-fixed): anchoring on `pwd` made a bare `git push` from INSIDE the wiki compare the wiki against itself and still block. Only the explicit `git -C <wiki>` and single-line `cd <wiki> && git push` shapes worked — not the natural agent workflow of cd-ing in one turn and pushing in the next. Comparing canonical push-destination URLs fixes both with one rule, and needs no wiki-specific configuration: `<project>.wiki.git` is simply a different URL from `<project>.git` on both GitHub and GitLab. The project anchor is wrapper-exported (`AUTONOMOUS_PROJECT_DIR`, [INV-131] pattern) rather than `pwd`-derived, because the hook's cwd is the pushing repo. Hooks stay zero-dependency shell — they read only what the wrapper exported, never parse conf. Fails closed throughout: a push is allowed only when BOTH destinations resolve AND differ. Missing anchor, unresolvable remote, or unparsable command all fall through to the trunk check. `PUSH_ALLOWED_REMOTE_URLS` is the explicit, auditable residual lever — an allowlist of specific destinations, deliberately not a boolean "disable trunk protection" switch. The sibling `block-commit-outside-worktree.sh` analogy does not transfer: worktree hygiene protects the local workspace, where another local repo genuinely is out of scope, so local identity is exact there. Its `resolve_git_command_cwd` helper is reused; its decision rule is not. Tests: 32 assertions (was 11), 12 red on the parent implementation — bare-push-from-wiki, second-clone in 3 command shapes, 5 URL spellings, and the allowlist. Layer 2 is untouched (self-contained by design). Docs: new INV-148 + docs/test-cases/push-destination-scoping.md.
Review of the previous commit found six verified ways its allow-gate could
be satisfied by a destination that was guessed rather than proven. Each was
reproduced against origin/main to confirm it was a regression introduced by
that commit, not pre-existing:
- chained pushes: `git push upstream feat/x && git push origin main` — the
operand lookup answered for the FIRST push while the trunk-ref parser read
refspecs from the whole line, so the second push rode through unexamined.
- quoted operand: `read -ra` does not strip quotes, so `git push "<url>" main`
canonicalized to `…team.git"` — a confidently WRONG destination that differs
from the anchor and is therefore allowed, while the real shell strips the
quotes and pushes to the protected trunk. This was the structural hole:
`canonical_remote_url` could never fail, so garbage always "resolved".
- `resolve_git_command_cwd` rc=2 fell back to the hook's cwd. But the cwd is a
DIFFERENT repository than the command's real target, so `env git -C <project>
push origin main` from a wiki cwd was allowed. Now rc=2 means UNKNOWN.
- path normalization: `//` was never collapsed and `.git` was stripped before
lowercasing, so `github.com//zxkane/…`, `git@github.com:/zxkane/…`, and
`…team.GIT` each differed from the anchor. The first two resolve to the real
repository (confirmed via `git ls-remote`).
- `remote.pushDefault` / `branch.<b>.pushRemote` in the project checkout moved
the anchor's own destination, silently switching the guard off repo-wide.
The anchor is now matched against ALL of its remotes, not its bare-push
destination, so local config cannot shrink the protected set.
- `${url#*@}` was not scoped to the host segment, so an `@` in a PATH collapsed
two unrelated hosts onto one canonical value (a false positive). Host and
path are now normalized separately; IPv6 literals keep their brackets.
Also corrects claims the code did not implement: git's push precedence is
`branch.<b>.pushRemote` → `remote.pushDefault` → `branch.<b>.remote` → origin
(pushRemote was not consulted at all), and TC-BP-19e passed vacuously — it now
plants a cwd file the pattern would expand onto, and fails if `set -f` is
removed. The doc's "unparsable shapes still block" line asserted verification
that had not happened; it now states the real consequence — the wiki allowance
covers only shapes resolve_git_command_cwd can parse, and widening that grammar
is the way to allow more, never relaxing fail-closed.
Tests 32 → 48: TC-BP-20..24 pin each finding above, TC-BP-25 asserts both
wrappers export the anchor (dropping it is fail-closed, so nothing else would
catch it). Verified non-vacuous by reverting `set -f` and the export in turn.
Docs: INV-148 rewritten around "proven destination + anchor owns it"; the two
wrapper flow docs now document the startup exports next to BASE_BRANCH.
Second review pass confirmed the previous commit fixed all six findings, but found that its own fixes opened two new holes. Both verified as regressions against origin/main (which blocks each), not hypotheticals: - DNS/path equivalence. A trailing dot on a hostname is DNS-equivalent, and `.`/`..` path segments resolve server-side, so `https://github.com./…`, `git@github.com.:…`, `…/zxkane/../zxkane/…` and `…/./zxkane/…` each reach the real repository while comparing unequal to the anchor. Host trailing dots are now stripped and path dot-segments resolved as pure string work (no filesystem is consulted; a `..` that would escape the root is dropped, so no traversal survives into the comparison key). - Unreadable anchor read as "not mine". `anchor_owns_destination` returned 1 both for "read the anchor, it owns no such remote" and for "could not read the anchor at all", so an anchor that is not a git repo, has no remotes, or has an unreadable `.git` (cross-user permissions, a safe.directory refusal) allowed a direct trunk push. It is now tri-state — 2 means unknown — and the hook only allows on 1. A guard whose protected set is unknown must protect everything, or a mis-set anchor becomes a blanket opt-out. This is a distinct class from a MISSING anchor, which falls back to the cwd and was already fail-closed, which is why TC-BP-13c could not catch it. Also makes TC-BP-24 non-vacuous: its original fixture passes even on the first cut, so it pinned nothing. TC-BP-24b uses a path embedding the project's own host after an `@`, which an unscoped `${url#*@}` collapses onto the project. The reviewer also reported `--repo <url>` as a bypass; that does not reproduce (it blocks on this branch and on origin/main alike), so no change was made for it. `--signed` IS a real pre-existing Layer-1 gap in the shared flag list — it takes no separate-argument value, so `git push --signed origin main` mis-parses to the current branch — but it is unchanged by this PR and gets its own issue. Tests 48 → 56: TC-BP-26 (four equivalent spellings), TC-BP-27 (unreadable and remote-less anchors, plus a counter-proof that a readable non-owning anchor still allows), TC-BP-24b. Each verified non-vacuous by reverting its fix and confirming only the intended assertions flip. Docs: corrected the stale 44/TC-BP-01..24 counts and the red/green enumeration to measured values — 20 of 56 red on the PR parent, 14 of 56 on the first cut, with the two sets deliberately barely overlapping.
216a906 to
39541bf
Compare
|
Thanks for this — the false positive is real, the The core issue: local identity is a proxy for the destination, and a false oneTrunk protection guards the project's remote trunk, so the question it has to answer is "where does this push land?" — not "which local checkout issued it?" Comparing A second independent clone of this project has a different Both clones had And the wiki case was only half-fixed. Anchoring identity on So the explicit What changedCompare canonical push-destination URLs against a wrapper-exported project anchor, and allow only when the destination is proven and the anchor is readable and doesn't own it. To your question about a config switch for specific repo URLs — mostly not needed, and this is the nice part: the wiki relationship is derivable. A wiki lives at The anchor is wrapper-exported ( The sibling-guard analogy is a good precedent for the helper, but not for the rule: worktree hygiene protects the local workspace, where another local repo genuinely is out of scope, so local identity is exact there. Two review passes found nine more holes in my own workWorth being explicit about, since it shows where the sharp edges are. The first pass found six bypasses in my initial rework — chained pushes ( The second pass then found that those fixes had opened two more: DNS-equivalent trailing-dot hostnames with The common thread is one rule, now stated as an invariant: an allow requires proof, and every form of uncertainty blocks. Unparsable command, ambiguous operand, multiple pushes, unreadable anchor, un-canonicalizable URL — all fall through to the trunk check. One consequence to flag, because it's a deliberate limitation rather than an oversight: the wiki allowance only covers command shapes Tests and docs11 → 56 assertions. Measured red/green: 20 of 56 fail on this PR's parent and 14 of 56 on my first cut, and the two sets barely overlap by design — the parent is red where it's too strict or blind to the destination, the first cut where it was too permissive. A pin failing on only one leaves the other failure mode unguarded. Every new test was verified non-vacuous by reverting its fix and confirming only the intended assertions flip; that's how I caught that the original Also added Two things left for you
One pre-existing bug surfaced along the way, unrelated to this PR and unchanged by it: |
## Summary - stop bare `--signed` from consuming the remote operand - preserve Git's value-taking `--repo` precedence, including positional repository overrides - add hook and direct parser regressions plus a test-case document ## Design - [x] Design canvas not required for this narrow shell-parser fix ## Test Plan - [x] Test cases documented - [x] Focused parser suite passes (24/24) - [x] Shared hook suites pass (8/8, 186/186, 9/9) - [x] `bash -n` passes - [x] CI-level ShellCheck passes - [x] Code simplification review passed - [x] Independent PR review passed with no findings - [x] E2E is not applicable to pure shell parsing - [ ] CI checks pass ## Scope Correction Git's own parser confirms that `--repo` consumes a value and a later positional repository overrides it. Therefore `git push --repo origin main` has no explicit refspec; from a feature branch its destination ref is that feature branch. The security-sensitive inverse, running `git push --repo origin feat/foo` from `main`, remains blocked and is covered by regression tests. ## Integration Note PR #539 adds the same remote-operand helper. The branches intentionally conflict in the parser and test files, requiring conflict resolution to preserve this corrected implementation instead of silently accepting a stale duplicate. Closes #542 --------- Co-authored-by: Kane Zhu <843303+zxkane@users.noreply.github.com>
Problem
block-push-to-main.shblocks any push whose destination ref resolves to the trunk, but it never asks which repository the push targets. Trunk protection exists to route changes to this project through a PR — it fired on pushes to unrelated repositories too, where there is no PR flow to route through and nothing to protect.The case that surfaced it: publishing a design doc to a GitLab project wiki. A wiki lives in a separate
<project>.wiki.gitrepository whose default — and only — branch ismain. Every wiki update tripped the guard, so wiki pages could not be pushed at all. There is no workaround from inside the session: the hook has no opt-out env var, and the only escapes are editing the project'ssettings.jsonto drop the hook or patching the hook script — both of which mean weakening a safety guard to publish a doc.The same false positive hits any sibling checkout or vendored dependency that happens to sit on its trunk.
Notably,
block-commit-outside-worktree.shalready does this correctly — it captureshook_common_dirand exits 0 when the command targets a different repository.block-push-to-main.shwas simply missing the equivalent check, so the two sibling guards disagreed on what "this repo" means.Fix
Resolve the target repository with the existing
resolve_git_command_cwdhelper (it already understandsgit -C <path> pushandcd <path> && git push), then comparegit-common-dirthe wayblock-commit-outside-worktree.shalready does, andexit 0when the push lands in a different repository.No new helper — this reuses what
lib.shalready exports and mirrors the identity check the sibling guard was already performing. +29 lines in the hook, all additive.Two deliberate properties:
git-common-dirrather thangit-dirkeeps a linked worktree of this project counted as this project — otherwise the guard could be sidestepped by pushing from a worktree. Pinned by TC-BP-14/15.Tests
Extends
tests/unit/test-block-push-regex.shwith 4 cases — 2 reproducing the false positive, 2 pinning the escape-hatch boundary:mainviagit -C<project>.wiki.gitmainviacd &&mainviagit -C <self>mainfrom a linked worktree15/15 pass. On the unmodified hook it is 13 pass / 2 fail — the two new allow-cases are exactly the false positive. TC-BP-14/15 passed before the change too, confirming the scoping narrows nothing it should still catch.
Additionally verified by hand, since
parse_push_target_refspecreads the current branch and only runs on the same-repo path — baregit pushin all three shapes:No regressions in the suites that share
lib.sh:test-block-commit-outside-worktree.sh,test-install-git-pre-push.sh,test-chp-commit-file.shall pass.shellcheck -xclean.Note
Branched directly from
upstream/main(3610c2b) so this carries only the fix — none of my fork's downstream adaptations.