From cf5aa3a629eccf05d16a6338f55681ce8cad19a0 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 26 Jul 2026 20:01:16 -0500 Subject: [PATCH 1/3] fix(coord): run the ledger gate via pre-commit instead of a standalone hook Two tools cannot both own .git/hooks/pre-commit. install-git-hooks.ps1 refused to overwrite a foreign hook, so `pre-commit install` responded by renaming ours to pre-commit.legacy and calling it from its own shim. That works on POSIX and FAILS ON WINDOWS -- pre-commit invokes the legacy hook from a Python subprocess, which cannot resolve the /bin/sh shebang: ExecutableNotFoundError: Executable `/bin/sh` not found Measured 2026-07-27: EVERY commit in the repo was blocked until the shim was uninstalled. Note the trap -- pre-commit.legacy EXISTED, so a presence check reported success. Only a real commit revealed it. The ledger gate is now a `local` hook in .pre-commit-config.yaml (id: ledger-gate, always_run, pass_filenames: false -- it inspects the staged TREE, not a file list). pre-commit owns the hook file alone, so the contention is impossible rather than managed, and a fresh clone gets every gate from `pre-commit install` with no hand-merged file to remember. install-git-hooks.ps1 now: * installs ONLY the claim gate (commit-msg -- it cannot move, since only commit-msg receives the message file); * MIGRATES an existing install by removing our old standalone pre-commit hook and its ledger_check.py copy. Leaving them would make the next `pre-commit install` chain to .legacy and re-break commits; * reports the real state under -Status, including a STALE leftover hook; and * fails LOUD when pre-commit is absent, because the ledger gate's availability is now coupled to it. That coupling is a genuine trade-off of this design, so it is announced rather than buried. Verified by a real commit -- both gates observed firing, not inferred from a file existing. --- .pre-commit-config.yaml | 20 ++++++ scripts/coord/install-git-hooks.ps1 | 106 +++++++++++++++++----------- 2 files changed, 85 insertions(+), 41 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1ec6d77..2ae0f49 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,7 @@ # Pre-commit hooks — surface lint/format/secret/SAST issues locally before they reach a commit, # mirroring the CI gate (ruff, bandit, gitleaks). One-time setup per clone: # pip install pre-commit && pre-commit install +# pwsh -NoProfile -File scripts/coord/install-git-hooks.ps1 # claim gate (commit-msg) # Run across the whole tree on demand: pre-commit run --all-files # Keep hook versions roughly in step with CI (.github/workflows/security.yml) and pyproject. repos: @@ -8,6 +9,25 @@ repos: # CI/dev ruff on a version (language: system uses the installed ruff from the active venv). - repo: local hooks: + # The LEDGER GATE runs here rather than as a standalone .git/hooks/pre-commit, and that is a + # deliberate reversal — see scripts/coord/install-git-hooks.ps1 for the full reasoning. + # + # Short version: two tools cannot both own .git/hooks/pre-commit. install-git-hooks.ps1 refuses + # to overwrite a foreign hook, and `pre-commit install` responds by moving the existing hook to + # pre-commit.legacy and calling it from its own shim — which WORKS ON POSIX AND FAILS ON + # WINDOWS, because pre-commit invokes the legacy hook from a Python subprocess that cannot + # resolve `#!/bin/sh` (ExecutableNotFoundError). Measured 2026-07-27: every commit in the repo + # was blocked. Owning one file with one tool makes the contention impossible instead of managed. + # + # always_run + pass_filenames: false — the gate inspects the staged TREE (which ADR/BACKLOG + # numbers the commit introduces), not a list of changed files, so it must run even when no file + # it "owns" changed. + - id: ledger-gate + name: ledger gate (ADR/BACKLOG number reuse) + entry: python scripts/hooks/ledger_check.py + language: system + pass_filenames: false + always_run: true - id: ruff-format name: ruff format entry: ruff format diff --git a/scripts/coord/install-git-hooks.ps1 b/scripts/coord/install-git-hooks.ps1 index 89c2a32..4fce2ae 100644 --- a/scripts/coord/install-git-hooks.ps1 +++ b/scripts/coord/install-git-hooks.ps1 @@ -1,7 +1,7 @@ <# .SYNOPSIS - Install the ledger gate as a pre-commit hook in the SHARED .git/hooks — one copy governs every - worktree at once. + Install the CLAIM gate (commit-msg) in the SHARED .git/hooks — one copy governs every worktree at + once. The LEDGER gate is no longer installed here; it runs via .pre-commit-config.yaml. .DESCRIPTION `.git/hooks` lives in the COMMON git directory, which every linked worktree shares. So a single file @@ -17,13 +17,28 @@ the worktree gate inspects tool arguments, so a file written by a shell command is invisible to it. This is that backstop. - The hook shells to `python scripts/hooks/ledger_check.py`, which is stdlib-only and imports nothing - from messagefoundry -- most worktrees have no .venv, and a gate that silently skips is worse than no - gate at all. If python is missing entirely it prints a loud warning and lets the commit through - (fail-open); CI re-runs the same rules with --ci, so nothing reaches main unchecked. + ⭐ WHY THE LEDGER GATE MOVED OUT OF HERE (2026-07-27). It used to be installed as a standalone + .git/hooks/pre-commit. Two tools cannot both own that file: this script refused to overwrite a + foreign hook, and `pre-commit install` responded by renaming ours to pre-commit.legacy and calling + it from its own shim. That works on POSIX and FAILS ON WINDOWS -- pre-commit invokes the legacy hook + from a Python subprocess, which cannot resolve `#!/bin/sh`: - Run from a plain terminal. `git commit --no-verify` bypasses it -- that is a guardrail, not a security - boundary, and the --ci leg is the backstop for exactly that. + ExecutableNotFoundError: Executable `/bin/sh` not found + + Measured: every commit in the repo was blocked until the shim was uninstalled. Note that + pre-commit.legacy EXISTING did not indicate success -- only a real commit did. The ledger gate is + therefore a `local` hook in .pre-commit-config.yaml (id: ledger-gate) and pre-commit owns the file + alone. Running this script now MIGRATES an old install by removing our standalone hook. + + TRADE-OFF, stated because it is real: the ledger gate's availability is now coupled to pre-commit + being installed. This script warns loudly when it is not. The claim gate stays a commit-msg hook -- + only commit-msg receives the message file, so it cannot move. + + ledger_check.py is stdlib-only and imports nothing from messagefoundry, so it still runs in a + worktree with no project .venv. + + Run from a plain terminal. `git commit --no-verify` bypasses both -- that is a guardrail, not a + security boundary, and the --ci leg is the backstop for exactly that. .EXAMPLE pwsh -NoProfile -File scripts\coord\install-git-hooks.ps1 @@ -56,12 +71,19 @@ $commitMsg = Join-Path $hooksDir "commit-msg" $claimMarker = "MessageFoundry claim gate" if ($Status) { - $installed = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match [regex]::Escape($marker)) + $stale = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match [regex]::Escape($marker)) $claimInstalled = (Test-Path $commitMsg) -and ((Get-Content $commitMsg -Raw -EA SilentlyContinue) -match [regex]::Escape($claimMarker)) + # The ledger gate is a pre-commit hook now, so "is it armed" means "did pre-commit install its + # shim" -- reporting on OUR marker would say 'not installed' for a perfectly healthy setup. + $pcShim = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match 'File generated by pre-commit') Write-Host "hooks dir : $hooksDir" - Write-Host "pre-commit : $(if ($installed) { 'INSTALLED (ledger gate)' } elseif (Test-Path $preCommit) { 'present, but NOT ours' } else { 'not installed' })" Write-Host "commit-msg : $(if ($claimInstalled) { 'INSTALLED (claim gate)' } elseif (Test-Path $commitMsg) { 'present, but NOT ours' } else { 'not installed' })" - Write-Host "worktrees : $(@(& git -C $RepoRoot worktree list).Count) share this hook" + Write-Host "pre-commit : $(if ($pcShim) { 'pre-commit framework (carries the ledger gate + leak gate)' } elseif ($stale) { 'STALE standalone ledger hook -- re-run this script to migrate' } elseif (Test-Path $preCommit) { 'present, but NOT ours' } else { 'NOT INSTALLED -- run: pre-commit install' })" + if ($stale) { + Write-Host " ^ a leftover standalone hook will make `pre-commit install` chain to" -ForegroundColor Yellow + Write-Host " pre-commit.legacy, which FAILS on Windows and blocks every commit." -ForegroundColor Yellow + } + Write-Host "worktrees : $(@(& git -C $RepoRoot worktree list).Count) share these hooks" return } @@ -81,38 +103,28 @@ if ($Uninstall) { return } -if ((Test-Path $preCommit) -and ((Get-Content $preCommit -Raw) -notmatch [regex]::Escape($marker))) { - throw "A pre-commit hook that is not ours already exists at $preCommit. Refusing to overwrite it -- merge them by hand." -} if ((Test-Path $commitMsg) -and ((Get-Content $commitMsg -Raw) -notmatch [regex]::Escape($claimMarker))) { throw "A commit-msg hook that is not ours already exists at $commitMsg. Refusing to overwrite it -- merge them by hand." } New-Item -ItemType Directory -Force -Path $hooksDir | Out-Null -# Resolve the checker from the COMMON dir, not from a working tree: a worktree can be on any branch (or a -# detached HEAD), and a hook that points into one would break the moment that branch lacks the file. -Copy-Item (Join-Path $RepoRoot "scripts\hooks\ledger_check.py") (Join-Path $hooksDir "ledger_check.py") -Force - -# LF endings and no BOM: git runs this through sh, which chokes on CRLF. -$hook = @' -#!/bin/sh -# MessageFoundry ledger gate -- INSTALLED COPY. Source: scripts/hooks/ledger_check.py -# Lives in the SHARED .git/hooks, so ONE copy governs every worktree, survives any branch switch, and -# fires for EVERY write route (Edit tool, shell redirect, VS Code, a subagent) -- it inspects the staged -# TREE, not a tool call. -# Re-install after changing the source: pwsh -NoProfile -File scripts/coord/install-git-hooks.ps1 -HOOK_DIR=$(dirname "$0") -PY=python -command -v python >/dev/null 2>&1 || PY=python3 -if ! command -v "$PY" >/dev/null 2>&1; then - echo "MessageFoundry: python not found -- THE LEDGER GATE IS OFF for this commit." >&2 - exit 0 -fi -exec "$PY" "$HOOK_DIR/ledger_check.py" -'@ -replace "`r`n", "`n" - -[System.IO.File]::WriteAllText($preCommit, $hook, (New-Object System.Text.UTF8Encoding $false)) +# --- MIGRATION: retire our OWN old pre-commit hook ------------------------------------------------- +# The ledger gate used to be installed here as a standalone .git/hooks/pre-commit. It now runs as a +# `local` hook inside .pre-commit-config.yaml instead. Leaving the old copy in place is NOT harmless: +# `pre-commit install` would find a foreign hook, move it to pre-commit.legacy and call it from its own +# shim -- which fails on Windows (see the note below) and blocks EVERY commit. Remove ours; never touch +# a hook that is not ours. +if ((Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match [regex]::Escape($marker))) { + Remove-Item -LiteralPath $preCommit -Force + $legacy = "$preCommit.legacy" + if ((Test-Path $legacy) -and ((Get-Content $legacy -Raw -EA SilentlyContinue) -match [regex]::Escape($marker))) { + Remove-Item -LiteralPath $legacy -Force + } + Write-Host "Migrated: the standalone ledger pre-commit hook was REMOVED." -ForegroundColor Yellow + Write-Host " The ledger gate now runs via .pre-commit-config.yaml (id: ledger-gate)." -ForegroundColor Yellow +} +Remove-Item -LiteralPath (Join-Path $hooksDir "ledger_check.py") -Force -EA SilentlyContinue # --- claim gate (BACKLOG #309) ------------------------------------------------------------------- # A commit-msg hook, because only commit-msg is handed the message file (as $1). The claim gate keys off @@ -137,16 +149,28 @@ exec "$PY" "$HOOK_DIR/claim_check.py" "$1" [System.IO.File]::WriteAllText($commitMsg, $claimHook, (New-Object System.Text.UTF8Encoding $false)) # Git for Windows does not need the exec bit, but a WSL/Linux checkout of the same repo would. -if ($IsLinux -or $IsMacOS) { & chmod +x $preCommit; & chmod +x $commitMsg } +if ($IsLinux -or $IsMacOS) { & chmod +x $commitMsg } Write-Host "" -Write-Host "MessageFoundry git hooks INSTALLED." -ForegroundColor Green -Write-Host " pre-commit: $preCommit" -Write-Host " $(Join-Path $hooksDir 'ledger_check.py') (ledger gate)" +Write-Host "MessageFoundry claim gate INSTALLED." -ForegroundColor Green Write-Host " commit-msg: $commitMsg" Write-Host " $(Join-Path $hooksDir 'claim_check.py') (claim gate, BACKLOG #309)" Write-Host " governs : all $(@(& git -C $RepoRoot worktree list).Count) worktree(s) of this repo, immediately" Write-Host "" + +# The ledger gate now rides on pre-commit. If pre-commit is not installed, say so LOUDLY -- a gate that +# is simply absent looks identical to one that passed. +$pcInstalled = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match 'File generated by pre-commit') +if ($pcInstalled) { + Write-Host "Ledger gate: ARMED via .pre-commit-config.yaml (id: ledger-gate)." -ForegroundColor Green +} else { + Write-Host "!! LEDGER GATE IS NOT ARMED." -ForegroundColor Red + Write-Host " It runs as a pre-commit hook now, and pre-commit is not installed in this repo." -ForegroundColor Red + Write-Host " Nothing is stopping two sessions colliding on an ADR/BACKLOG number. Fix with:" -ForegroundColor Red + Write-Host " pip install pre-commit" -ForegroundColor Red + Write-Host " pre-commit install" -ForegroundColor Red + Write-Host "" +} Write-Host "Ledger gate: blocks a commit that reuses an ADR/BACKLOG number, or adds an ADR with no index row." Write-Host "Claim gate : blocks a CODE commit whose subject says 'BACKLOG #N' unless THIS worktree claims N," Write-Host " so two sessions cannot build the same item in parallel. Docs-only commits pass." From 6dcaab80823fdae031d6420e38bb51e208460c9e Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 26 Jul 2026 20:08:42 -0500 Subject: [PATCH 2/3] test(coord): assert the ledger gate is WIRED, not just correct The 17 existing tests exercise ledger_check.py's logic against a throwaway repo. None of them notices if the gate is never invoked -- and that is exactly what just happened: the gate had to move out of .git/hooks/pre-commit because two tools contend for that file and their chaining fails on Windows. Every logic test stayed green throughout. Two wiring assertions: * the ledger-gate hook exists in .pre-commit-config.yaml, points at ledger_check.py, and carries always_run + pass_filenames: false (it inspects the staged TREE, so without always_run a commit touching no file it "owns" would skip the gate entirely); * install-git-hooks.ps1 does NOT write a pre-commit hook again, and DOES still migrate an old standalone install away -- otherwise the two-owner conflict returns and re-breaks every commit. Mutation-verified: renaming the hook id reds it, and so does flipping always_run to false. Uses a plain `import yaml`, deliberately not pytest.importorskip. pyyaml is pinned in requirements.lock and constraints.lock so it is always present where CI runs; importorskip would turn a missing dependency into a silent SKIP, which is the precise failure mode this test exists to catch. --- tests/test_ledger_check.py | 59 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_ledger_check.py b/tests/test_ledger_check.py index 667d127..988d8cc 100644 --- a/tests/test_ledger_check.py +++ b/tests/test_ledger_check.py @@ -370,3 +370,62 @@ def test_ci_mode_skips_the_ownership_rule_but_still_catches_a_reused_number(repo code, out = run_check(repo, "--ci") assert code == 1 assert "ADR 0001 already exists" in out + + +# -------------------------------------------------------------------------------------------------- +# WIRING. Everything above tests the gate's LOGIC against a throwaway repo. None of it notices if the +# gate is never invoked -- and on 2026-07-27 that is exactly what happened: the ledger gate had to move +# out of .git/hooks/pre-commit because `pre-commit install` and install-git-hooks.ps1 both want that +# file, and their chaining fails on Windows. Logic tests stayed green throughout. These assert the gate +# is actually WIRED UP, which is the property that was silently lost. +# -------------------------------------------------------------------------------------------------- + +_CONFIG = Path(__file__).resolve().parents[1] / ".pre-commit-config.yaml" +_INSTALLER = Path(__file__).resolve().parents[1] / "scripts" / "coord" / "install-git-hooks.ps1" + + +def _ledger_hook() -> dict[str, object]: + """The ledger-gate entry from .pre-commit-config.yaml, or fail loudly. + + Plain import, deliberately NOT pytest.importorskip: pyyaml is pinned in requirements.lock and + constraints.lock, so it is always present where CI runs. importorskip would turn a missing + dependency into a silent SKIP — and a wiring test that skips is exactly the failure this test + exists to catch. + """ + import yaml + + cfg = yaml.safe_load(_CONFIG.read_text(encoding="utf-8")) + for repo in cfg["repos"]: + for hook in repo.get("hooks", []): + if hook.get("id") == "ledger-gate": + return hook + raise AssertionError( + "no 'ledger-gate' hook in .pre-commit-config.yaml — the ledger gate is NOT wired up, and every " + "logic test above still passes" + ) + + +def test_the_ledger_gate_is_wired_into_pre_commit() -> None: + hook = _ledger_hook() + assert "ledger_check.py" in str(hook["entry"]), hook["entry"] + # It inspects the staged TREE (which ADR/BACKLOG numbers the commit introduces), not a file list, + # so it must run even when no file it "owns" changed. Without always_run a commit that touches only + # unrelated files would skip the gate entirely. + assert hook.get("always_run") is True, "ledger-gate must be always_run" + assert hook.get("pass_filenames") is False, "ledger-gate must not be given a file list" + + +def test_the_installer_no_longer_writes_a_pre_commit_hook() -> None: + """The contention must stay impossible, not merely resolved once. + + If install-git-hooks.ps1 starts writing .git/hooks/pre-commit again, the next `pre-commit install` + chains to pre-commit.legacy and — on Windows — blocks every commit in the repo. + """ + src = _INSTALLER.read_text(encoding="utf-8") + assert "WriteAllText($preCommit" not in src, ( + "install-git-hooks.ps1 writes a pre-commit hook again — that re-creates the two-owner conflict" + ) + # ...and it must still MIGRATE an old standalone install away, or upgrading users stay broken. + assert "Remove-Item -LiteralPath $preCommit" in src, ( + "the installer must remove a previously-installed standalone ledger hook" + ) From e4ebe0524ba8b62387a7570e391441425eac75dc Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 26 Jul 2026 20:44:38 -0500 Subject: [PATCH 3/3] =?UTF-8?q?feat(coord):=20pre-push=20guard=20=E2=80=94?= =?UTF-8?q?=20refuse=20a=20direct=20push=20to=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the cutover this repository IS the published artifact. A push to main is publication, immediately, and cannot be taken back: deleting the ref later does not un-publish content that was fetched, mirrored or indexed in between. There is no publish step left to catch anything. Server-side branch protection requires a PR and 12 status checks, but enforce_admins is FALSE, so the repository owner bypasses all of it. The realistic trigger is not malice -- it is one click, because VS Code's Sync/Push button does not distinguish "my feature branch" from "main", and the editor is where most pushes originate. The old mirror clone carried a Gate-Provenance pre-push hook that covered this class; it was quarantined at cutover and nothing replaced it. Protects refs/heads/main and refs/heads/cla-signatures (written by the CLA action, never by a human -- and every PR wedges if it is damaged). Deletions are refused too: deleting main is worse than pushing to it. WHAT THIS IS NOT. A guardrail, not a boundary: --no-verify skips it and it is local-only, so another machine is unprotected. It removes the ACCIDENT, not the capability. The durable server-side fix is enforce_admins=true, deliberately NOT done yet: a known flaky test (BACKLOG #17) has now blocked two consecutive PRs, and removing the override while a flake can strand a merge would trade an accidental-push risk for a cannot-ship risk. Sequence it after #17. The escape hatch is MEFOR_ALLOW_DIRECT_PUSH=1, deliberately NOT --no-verify: a distinct variable is greppable in shell history and cannot be reached by the muscle memory that skips every other hook. Fails OPEN on malformed stdin. The guard exists to stop an accident; crashing on unexpected input would BE one. VERIFIED end-to-end, not by inspection: a real `git push origin :main` is REFUSED (exit 1), while the control -- pushing the branch to its own ref -- still succeeds. Eight tests drive the exact stdin contract git uses for a pre-push hook, plus a wiring test that the installer actually installs it; mutation-verified by disabling the protection and watching four go red. --- scripts/coord/install-git-hooks.ps1 | 43 ++++++++++- scripts/hooks/push_guard.py | 84 +++++++++++++++++++++ tests/test_push_guard.py | 109 ++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 scripts/hooks/push_guard.py create mode 100644 tests/test_push_guard.py diff --git a/scripts/coord/install-git-hooks.ps1 b/scripts/coord/install-git-hooks.ps1 index 4fce2ae..d480b3c 100644 --- a/scripts/coord/install-git-hooks.ps1 +++ b/scripts/coord/install-git-hooks.ps1 @@ -69,6 +69,8 @@ $marker = "MessageFoundry ledger gate" # commit message, so a claim check bolted onto it would look installed and silently never fire. $commitMsg = Join-Path $hooksDir "commit-msg" $claimMarker = "MessageFoundry claim gate" +$prePush = Join-Path $hooksDir "pre-push" +$pushMarker = "MessageFoundry push guard" if ($Status) { $stale = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match [regex]::Escape($marker)) @@ -83,6 +85,8 @@ if ($Status) { Write-Host " ^ a leftover standalone hook will make `pre-commit install` chain to" -ForegroundColor Yellow Write-Host " pre-commit.legacy, which FAILS on Windows and blocks every commit." -ForegroundColor Yellow } + $pushInstalled = (Test-Path $prePush) -and ((Get-Content $prePush -Raw -EA SilentlyContinue) -match [regex]::Escape($pushMarker)) + Write-Host "pre-push : $(if ($pushInstalled) { 'INSTALLED (push guard)' } elseif (Test-Path $prePush) { 'present, but NOT ours' } else { 'NOT INSTALLED' })" Write-Host "worktrees : $(@(& git -C $RepoRoot worktree list).Count) share these hooks" return } @@ -99,6 +103,11 @@ if ($Uninstall) { Write-Host "Claim commit-msg hook REMOVED." -ForegroundColor Yellow $removed = $true } + if ((Test-Path $prePush) -and ((Get-Content $prePush -Raw) -match [regex]::Escape($pushMarker))) { + Remove-Item -LiteralPath $prePush -Force + Write-Host "Push guard pre-push hook REMOVED." -ForegroundColor Yellow + $removed = $true + } if (-not $removed) { Write-Host "Nothing to remove (no MessageFoundry hooks installed)." } return } @@ -106,6 +115,9 @@ if ($Uninstall) { if ((Test-Path $commitMsg) -and ((Get-Content $commitMsg -Raw) -notmatch [regex]::Escape($claimMarker))) { throw "A commit-msg hook that is not ours already exists at $commitMsg. Refusing to overwrite it -- merge them by hand." } +if ((Test-Path $prePush) -and ((Get-Content $prePush -Raw) -notmatch [regex]::Escape($pushMarker))) { + throw "A pre-push hook that is not ours already exists at $prePush. Refusing to overwrite it -- merge them by hand." +} New-Item -ItemType Directory -Force -Path $hooksDir | Out-Null @@ -148,13 +160,40 @@ exec "$PY" "$HOOK_DIR/claim_check.py" "$1" [System.IO.File]::WriteAllText($commitMsg, $claimHook, (New-Object System.Text.UTF8Encoding $false)) +# --- push guard ------------------------------------------------------------------------------------ +# Since the MEFORORG cutover this repo IS the published artifact -- a push to main is publication, with +# no publish step left to catch anything. Server-side protection requires a PR and 12 checks, but +# enforce_admins is false, so the owner bypasses all of it and VS Code's Sync button does not +# distinguish main from a feature branch. This restores the class of protection the old mirror clone's +# Gate-Provenance pre-push hook provided before it was quarantined at cutover. +Copy-Item (Join-Path $RepoRoot "scripts\hooks\push_guard.py") (Join-Path $hooksDir "push_guard.py") -Force + +$pushHook = @' +#!/bin/sh +# MessageFoundry push guard -- INSTALLED COPY. Source: scripts/hooks/push_guard.py +# Refuses a DIRECT push (or delete) of a protected branch. git feeds the refs on stdin. +# Re-install after changing the source: pwsh -NoProfile -File scripts/coord/install-git-hooks.ps1 +HOOK_DIR=$(dirname "$0") +PY=python +command -v python >/dev/null 2>&1 || PY=python3 +if ! command -v "$PY" >/dev/null 2>&1; then + echo "MessageFoundry: python not found -- THE PUSH GUARD IS OFF for this push." >&2 + exit 0 +fi +exec "$PY" "$HOOK_DIR/push_guard.py" "$@" +'@ -replace "`r`n", "`n" + +[System.IO.File]::WriteAllText($prePush, $pushHook, (New-Object System.Text.UTF8Encoding $false)) + # Git for Windows does not need the exec bit, but a WSL/Linux checkout of the same repo would. -if ($IsLinux -or $IsMacOS) { & chmod +x $commitMsg } +if ($IsLinux -or $IsMacOS) { & chmod +x $commitMsg; & chmod +x $prePush } Write-Host "" -Write-Host "MessageFoundry claim gate INSTALLED." -ForegroundColor Green +Write-Host "MessageFoundry hooks INSTALLED." -ForegroundColor Green Write-Host " commit-msg: $commitMsg" Write-Host " $(Join-Path $hooksDir 'claim_check.py') (claim gate, BACKLOG #309)" +Write-Host " pre-push : $prePush" +Write-Host " $(Join-Path $hooksDir 'push_guard.py') (refuses a direct push to main)" Write-Host " governs : all $(@(& git -C $RepoRoot worktree list).Count) worktree(s) of this repo, immediately" Write-Host "" diff --git a/scripts/hooks/push_guard.py b/scripts/hooks/push_guard.py new file mode 100644 index 0000000..d9da0de --- /dev/null +++ b/scripts/hooks/push_guard.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Push guard — refuse a DIRECT push to a protected branch. + +THE DEFECT THIS EXISTS FOR. Since the MEFORORG cutover this repository IS the published artifact: +there is no publish step left between a push and the public internet. A push to ``main`` is therefore +publication, immediately and irreversibly (deleting a ref later does not un-publish content that was +fetched, mirrored or indexed in between). + +Branch protection on the server requires a PR and 12 status checks -- but ``enforce_admins`` is false, +so the repository owner BYPASSES all of it. The realistic trigger is not malice, it is one click: +VS Code's Sync/Push button does not distinguish "my feature branch" from "main", and the editor is +where most pushes originate. + +This is the guard the old mirror clone's Gate-Provenance pre-push hook used to provide. That clone was +quarantined at cutover, and nothing replaced it. + +WHAT THIS IS NOT. A guardrail, not a security boundary: ``git push --no-verify`` skips it, and it is +local-only, so a different machine is unprotected. It removes the ACCIDENT, not the capability. The +durable server-side fix is ``enforce_admins=true``, which is deliberately NOT in place yet because a +known flaky test (BACKLOG #17) has blocked two consecutive PRs, and removing the override while a +flake can strand a merge trades an accidental-push risk for a cannot-ship risk. + +Stdlib only, like the other gates -- most worktrees have no project .venv. + +git hands a pre-push hook one line per ref on STDIN: + +A deletion has an all-zero local sha; it is refused too, since deleting main is worse than pushing to +it. Exit 0 allows the push, 1 refuses it. +""" + +from __future__ import annotations + +import os +import sys + +#: Refuse a direct push to any of these. `main` is the published branch; `cla-signatures` holds the +#: CLA Assistant's signature store and is written by the action, never by a human. +PROTECTED = frozenset({"refs/heads/main", "refs/heads/cla-signatures"}) + +_ZERO = "0" * 40 + + +def main(argv: list[str]) -> int: + # Escape hatch for the rare legitimate case, distinct from --no-verify so it is greppable in + # history and cannot be set by muscle memory. + if os.environ.get("MEFOR_ALLOW_DIRECT_PUSH") == "1": + print("push_guard: MEFOR_ALLOW_DIRECT_PUSH=1 — direct push ALLOWED.", file=sys.stderr) + return 0 + + offenders: list[tuple[str, bool]] = [] + for line in sys.stdin: + parts = line.split() + if len(parts) != 4: + continue + local_sha, remote_ref = parts[1], parts[2] + if remote_ref in PROTECTED: + offenders.append((remote_ref, local_sha.strip("0") == "")) + + if not offenders: + return 0 + + print("", file=sys.stderr) + print("MessageFoundry push guard — REFUSED", file=sys.stderr) + for ref, is_delete in offenders: + what = "DELETE" if is_delete else "direct push" + print(f" {what} to {ref}", file=sys.stderr) + print("", file=sys.stderr) + print( + " This repo IS the published artifact — a push to main is publication, immediately, and\n" + " cannot be taken back. Server-side branch protection requires a PR and 12 checks, but\n" + " enforce_admins is false, so the owner bypasses it. Nothing else would have stopped this.\n" + "\n" + " Push a branch and open a PR instead:\n" + " git switch -c && git push -u origin \n" + "\n" + " If you genuinely mean it: MEFOR_ALLOW_DIRECT_PUSH=1 git push ...", + file=sys.stderr, + ) + print("", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_push_guard.py b/tests/test_push_guard.py new file mode 100644 index 0000000..6a3f254 --- /dev/null +++ b/tests/test_push_guard.py @@ -0,0 +1,109 @@ +"""Tests for the push guard (scripts/hooks/push_guard.py). + +Since the MEFORORG cutover this repository IS the published artifact — a push to ``main`` is +publication, immediately, with no publish step left to catch anything. Server-side branch protection +requires a PR and 12 status checks, but ``enforce_admins`` is false, so the repository owner bypasses +all of it. The realistic trigger is one click on VS Code's Sync button while the current branch happens +to be ``main``. + +Every test feeds the script the EXACT stdin contract git uses for a pre-push hook — +`` ``, one line per ref — so what is asserted is the +interface git will actually invoke, not a convenience wrapper. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +_GUARD = _REPO / "scripts" / "hooks" / "push_guard.py" +_INSTALLER = _REPO / "scripts" / "coord" / "install-git-hooks.ps1" + +_SHA = "a" * 40 +_ZERO = "0" * 40 + + +def _run(refs: str, env_extra: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + env = None + if env_extra: + import os + + env = {**os.environ, **env_extra} + return subprocess.run( + [sys.executable, str(_GUARD)], + input=refs, + capture_output=True, + text=True, + env=env, + check=False, + ) + + +def test_a_direct_push_to_main_is_refused() -> None: + r = _run(f"refs/heads/x {_SHA} refs/heads/main {_ZERO}\n") + assert r.returncode == 1, r.stderr + assert "REFUSED" in r.stderr + + +def test_deleting_main_is_refused() -> None: + """A deletion has an all-zero LOCAL sha. Deleting main is worse than pushing to it.""" + r = _run(f"refs/heads/x {_ZERO} refs/heads/main {_SHA}\n") + assert r.returncode == 1, r.stderr + assert "DELETE" in r.stderr + + +def test_the_cla_signature_branch_is_protected() -> None: + """Written by the CLA Assistant action, never by a human — and every PR wedges if it is damaged.""" + r = _run(f"refs/heads/x {_SHA} refs/heads/cla-signatures {_ZERO}\n") + assert r.returncode == 1, r.stderr + + +def test_an_ordinary_branch_push_is_allowed() -> None: + """The control. Without this, a guard that refused EVERYTHING would look identical to a working one.""" + r = _run(f"refs/heads/x {_SHA} refs/heads/feature-y {_ZERO}\n") + assert r.returncode == 0, r.stderr + assert r.stderr.strip() == "" + + +def test_a_mixed_push_is_refused_because_one_ref_is_protected() -> None: + """`git push --all` sends every ref at once; one protected ref must sink the whole push.""" + r = _run( + f"refs/heads/a {_SHA} refs/heads/feature-y {_ZERO}\n" + f"refs/heads/b {_SHA} refs/heads/main {_ZERO}\n" + ) + assert r.returncode == 1, r.stderr + + +def test_the_escape_hatch_works_and_says_so() -> None: + """Deliberately NOT --no-verify: a distinct variable is greppable in shell history, and cannot be + reached by the muscle memory that skips every other hook at once.""" + r = _run( + f"refs/heads/x {_SHA} refs/heads/main {_ZERO}\n", + {"MEFOR_ALLOW_DIRECT_PUSH": "1"}, + ) + assert r.returncode == 0, r.stderr + assert "ALLOWED" in r.stderr + + +def test_malformed_stdin_does_not_crash_the_push() -> None: + """git sends nothing at all for some invocations. Fail OPEN on garbage rather than wedging pushes — + the guard exists to stop an accident, and crashing on unexpected input would BE one.""" + r = _run("not four fields\n\n") + assert r.returncode == 0, r.stderr + + +# -------------------------------------------------------------------------------------------------- +# WIRING. The tests above prove the logic. None of them notices if nothing ever installs the hook — +# the same gap that let the ledger gate sit silently un-invoked (see test_ledger_check.py). +# -------------------------------------------------------------------------------------------------- + + +def test_the_installer_wires_the_push_guard() -> None: + src = _INSTALLER.read_text(encoding="utf-8") + assert "push_guard.py" in src, "installer never copies the guard into .git/hooks" + assert "WriteAllText($prePush" in src, "installer never writes the pre-push hook" + # It must refuse to clobber someone else's pre-push hook, and remove its own on -Uninstall. + assert "Refusing to overwrite it" in src + assert "Remove-Item -LiteralPath $prePush" in src, "-Uninstall must remove the push guard"