Skip to content

fix(hooks): scope trunk protection to the project's own repository - #539

Merged
zxkane merged 4 commits into
zxkane:mainfrom
IanLiYi1996:fix/scope-git-guards-to-target-repo
Aug 2, 2026
Merged

fix(hooks): scope trunk protection to the project's own repository#539
zxkane merged 4 commits into
zxkane:mainfrom
IanLiYi1996:fix/scope-git-guards-to-target-repo

Conversation

@IanLiYi1996

Copy link
Copy Markdown
Contributor

Problem

block-push-to-main.sh blocks 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.git repository whose default — and only — branch is main. 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's settings.json to 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.sh already does this correctly — it captures hook_common_dir and exits 0 when the command targets a different repository. block-push-to-main.sh was 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_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 already exports and mirrors the identity check the sibling guard was already performing. +29 lines in the hook, all additive.

Two deliberate properties:

  • Not an escape hatch. Comparing git-common-dir rather than git-dir keeps 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.
  • Fails closed. Resolution uncertainty falls back to the inherited cwd, so an unparsable command is still checked rather than waved through.

Tests

Extends tests/unit/test-block-push-regex.sh with 4 cases — 2 reproducing the false positive, 2 pinning the escape-hatch boundary:

case expected
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. 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_refspec reads the current branch and only runs on the same-repo path — bare git push in all three shapes:

  • same repo on a feature branch → allow
  • same repo on trunk → block
  • a different repo sitting on trunk → allow

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.sh all pass. shellcheck -x clean.

Note

Branched directly from upstream/main (3610c2b) so this carries only the fix — none of my fork's downstream adaptations.

@IanLiYi1996
IanLiYi1996 marked this pull request as ready for review July 30, 2026 09:13
IanLiYi1996 and others added 4 commits August 2, 2026 07:42
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.
@zxkane
zxkane force-pushed the fix/scope-git-guards-to-target-repo branch from 216a906 to 39541bf Compare August 2, 2026 10:05
@zxkane

zxkane commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Thanks for this — the false positive is real, the resolve_git_command_cwd reuse is the right building block, and the observation that the two sibling guards disagreed is what made the underlying problem findable. I've pushed three commits on top rather than requesting changes, because the fix needed a different decision rule and that's easier to review as code than as prose.

The core issue: local identity is a proxy for the destination, and a false one

Trunk 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 git-common-dir answers the second, and the two come apart in both directions. I verified each of these by running the hook, and compared every result against main to confirm it was a regression rather than something pre-existing:

A second independent clone of this project has a different git-common-dir but pushes to this project's own trunk. Under the common-dir comparison it was allowed:

git -C <2nd-clone> push origin main               → 0 ALLOWED   (main: 2 blocked)
cd <2nd-clone> && git push origin main            → 0 ALLOWED   (main: 2 blocked)
git -C <2nd-clone> push origin HEAD:refs/heads/main → 0 ALLOWED (main: 2 blocked)

Both clones had origin pointing at this repo, so those pushes land on the protected trunk. TC-BP-14/15 pin only the linked-worktree case, which shares a common-dir — a narrower boundary than the "Not an escape hatch" property the description claims. Worth noting Layers 2/3 are weaker cover than they look here: Layer 2 installs per worktree/clone, so a freshly-created clone most likely has no pre-push hook at all.

And the wiki case was only half-fixed. Anchoring identity on pwd means a bare git push from inside the wiki compares the wiki against itself:

git push            (cwd = wiki clone) → 2 BLOCKED
git push origin main (cwd = wiki clone) → 2 BLOCKED

So the explicit git -C <wiki> and single-line cd <wiki> && git push shapes worked, but cd-ing into the wiki in one turn and pushing in the next — the most natural agent workflow — still didn't. The description's hand-verification note for the bare-push case doesn't reproduce; that's the shape it covers.

What changed

Compare 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 <project>.wiki.git, which canonicalizes differently from <project>.git on both GitHub and GitLab. So the same comparison that blocks the second clone allows the wiki, with zero configuration. I did add PUSH_ALLOWED_REMOTE_URLS for the residual case the comparison genuinely can't infer — a destination that is this project's trunk but which you've decided may be pushed directly (a mirror, a fork you own). It's an allowlist of specific destinations, deliberately not a boolean "disable trunk protection" switch, so an operator names what's exempt and a reader sees exactly what was exempted.

The anchor is wrapper-exported (AUTONOMOUS_PROJECT_DIR) rather than pwd-derived, following the existing BASE_BRANCH resolve-once/export-once pattern from INV-131PROJECT_DIR was already a required, validated conf value. That keeps hooks zero-dependency shell and cross-agent, instead of reaching for a Claude-specific variable.

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. resolve_git_command_cwd is reused; its decision rule isn't.

Two review passes found nine more holes in my own work

Worth being explicit about, since it shows where the sharp edges are. The first pass found six bypasses in my initial rework — chained pushes (git push a x && git push origin main, where the operand lookup answered for the first push while the trunk-ref parser read the whole line), quoted operands (read -ra doesn't strip quotes, so git push "<url>" main canonicalized to …team.git" — a confidently wrong destination, which is worse than an unresolved one because it satisfies the allow gate), resolve_git_command_cwd rc=2 falling back to the cwd, // and .GIT normalization gaps, remote.pushDefault silently moving the anchor, and @-in-path collapsing two hosts.

The second pass then found that those fixes had opened two more: DNS-equivalent trailing-dot hostnames with ./.. path segments, and anchor_owns_destination returning the same value for "doesn't own it" and "couldn't read the anchor" — making a mis-set anchor a blanket opt-out.

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 resolve_git_command_cwd can parse. env git -C <wiki> push origin main is rc=2 and therefore still blocked. Widening that grammar is the way to allow more shapes; relaxing fail-closed isn't.

Tests and docs

11 → 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 TC-BP-24 and the set -f glob test were passing vacuously.

Also added docs/pipeline/invariants.md INV-148 and docs/test-cases/push-destination-scoping.md, plus the startup-export docs in both wrapper flow files — the pipeline-docs-gate check would have failed as-is (I confirmed with the gate's own regexes: touches_pipeline=1, touches_docs=0).

Two things left for you

  • No linked issueclosingIssuesReferences is empty and there's no Closes #N. Could you add one, or say the word and I'll file it?
  • CI has never run — both workflows are sitting at action_required pending maintainer approval for the fork PR. I'll approve them.

One pre-existing bug surfaced along the way, unrelated to this PR and unchanged by it: --signed is listed among the value-taking flags in the shared parser, but it takes no separate-argument value, so git push --signed origin main mis-parses to the current branch and can evade Layer 1. It's on main too — I'll file it separately.

@zxkane
zxkane merged commit 3435d6e into zxkane:main Aug 2, 2026
6 checks passed
kane-review-agent Bot pushed a commit that referenced this pull request Aug 2, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants