Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
# 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:
# Ruff lint + format from the project's own environment, so pre-commit can never disagree with the
# 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
Expand Down
143 changes: 103 additions & 40 deletions scripts/coord/install-git-hooks.ps1
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -54,14 +69,25 @@ $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) {
$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
}
$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
}

Expand All @@ -77,76 +103,113 @@ 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
}

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."
}
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

# 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
# --- 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

# LF endings and no BOM: git runs this through sh, which chokes on CRLF.
$hook = @'
# --- claim gate (BACKLOG #309) -------------------------------------------------------------------
# A commit-msg hook, because only commit-msg is handed the message file (as $1). The claim gate keys off
# the SUBJECT declaring `BACKLOG #N`, so on pre-commit it would have nothing to read.
Copy-Item (Join-Path $RepoRoot "scripts\hooks\claim_check.py") (Join-Path $hooksDir "claim_check.py") -Force

$claimHook = @'
#!/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.
# MessageFoundry claim gate -- INSTALLED COPY. Source: scripts/hooks/claim_check.py
# commit-msg (NOT pre-commit): only this hook receives the message file, and the gate reads the subject.
# 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
echo "MessageFoundry: python not found -- THE CLAIM GATE IS OFF for this commit." >&2
exit 0
fi
exec "$PY" "$HOOK_DIR/ledger_check.py"
exec "$PY" "$HOOK_DIR/claim_check.py" "$1"
'@ -replace "`r`n", "`n"

[System.IO.File]::WriteAllText($preCommit, $hook, (New-Object System.Text.UTF8Encoding $false))
[System.IO.File]::WriteAllText($commitMsg, $claimHook, (New-Object System.Text.UTF8Encoding $false))

# --- claim gate (BACKLOG #309) -------------------------------------------------------------------
# A commit-msg hook, because only commit-msg is handed the message file (as $1). The claim gate keys off
# the SUBJECT declaring `BACKLOG #N`, so on pre-commit it would have nothing to read.
Copy-Item (Join-Path $RepoRoot "scripts\hooks\claim_check.py") (Join-Path $hooksDir "claim_check.py") -Force
# --- 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

$claimHook = @'
$pushHook = @'
#!/bin/sh
# MessageFoundry claim gate -- INSTALLED COPY. Source: scripts/hooks/claim_check.py
# commit-msg (NOT pre-commit): only this hook receives the message file, and the gate reads the subject.
# 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 CLAIM GATE IS OFF for this commit." >&2
echo "MessageFoundry: python not found -- THE PUSH GUARD IS OFF for this push." >&2
exit 0
fi
exec "$PY" "$HOOK_DIR/claim_check.py" "$1"
exec "$PY" "$HOOK_DIR/push_guard.py" "$@"
'@ -replace "`r`n", "`n"

[System.IO.File]::WriteAllText($commitMsg, $claimHook, (New-Object System.Text.UTF8Encoding $false))
[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 $preCommit; & chmod +x $commitMsg }
if ($IsLinux -or $IsMacOS) { & chmod +x $commitMsg; & chmod +x $prePush }

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 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 ""

# 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."
Expand Down
84 changes: 84 additions & 0 deletions scripts/hooks/push_guard.py
Original file line number Diff line number Diff line change
@@ -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:
<local ref> <local sha> <remote ref> <remote sha>
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 <branch> && git push -u origin <branch>\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:]))
Loading
Loading