Skip to content

chore: add git hooks that refuse commits on main and unsafe pushes - #1172

Merged
mforce merged 9 commits into
mainfrom
chore/git-hooks-backstop
Oct 10, 2026
Merged

mforce merged 9 commits into
mainfrom
chore/git-hooks-backstop

Conversation

@mforce

@mforce mforce commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Part of #1171 (the Pi and Hermes wiring follows in its own PR, which closes it)

.githooks/pre-commit and a new .githooks/pre-push back up the agent git guard (#1158) for every harness and for people. They work from what git itself knows, the checked-out branch and the refs being pushed, so no command line is parsed.

Hook Refuses Message points to
pre-commit a commit while refs/heads/main is checked out. This runs first, before SKIP_HOOKS and before any test step. A branch lookup that fails for any reason other than a detached HEAD also refuses. git switch -c <type>/<topic>, then commit
pre-push a remote ref of refs/heads/main, including deletes git switch -c <type>/<topic>, then push that branch
pre-push moving or replacing a pushed tag: an existing refs/tags/* whose sha changes, even to a descendant or the same commit push a new tag
pre-push a non-fast-forward update: the remote sha is non-zero and not an ancestor of the local sha. This covers --force, +refspec and --force-with-lease alike, and a remote that has commits you lack. fetch, merge, push a new commit
pre-push a local branch name that differs from the remote branch name (the src:dst mistake behind #900). HEAD is resolved to the checked-out branch first. git branch --show-current, then git push -u origin <that-branch>

Allowed: pushing your branch (git push, git push -u origin HEAD, git push origin <branch>), new tags, and deleting a branch other than main. A name mismatch is also allowed when a remote.<name>.push refspec maps that source. Short names in the refspec read as branches, and a leading + is ignored here because the history check still refuses a forced update. A HEAD:<dst> mapping covers only the checked-out branch. Paseo PR checkouts push that way, from a suffixed local branch (remote.paseo-pr-934.push = HEAD:refs/heads/feat/828-…). Wildcard refspecs are not supported and refuse.

Turning it on (the maintainer runs this once)

git -C /home/mforce/dev/cluckwork config core.hooksPath .githooks

core.hooksPath is unset on this machine today. The setting lives in the shared repository config, so it also applies to every linked worktree under .cluckwork-slices/ and .claude/worktrees/. Because the path is relative, each worktree runs the .githooks/ of its own checkout. A branch cut before this PR merges has no pre-push until it merges main.

It also turns on the existing hooks: the pre-commit test tripwire (domain and application unit tests on staged .cs, npm run typecheck and lint on staged web/) and commit-msg. To keep the guard without the test steps for every agent, also run:

git -C /home/mforce/dev/cluckwork config cluckwork.hookTests false

Decided by the maintainer

Pi and Hermes (for the follow-up)

Both harnesses can block a shell command before it runs. Until the follow-up lands, only these git hooks cover them, and nothing stops their gh pr merge.

  • Pi 1.0.4. docs/extensions.md in the installed package (@earendil-works/pi-coding-agent): "tool_call can mutate input or block execution", returning { block: true, reason }. "A tool_call handler failure blocks the tool as a fail-safe." docs/configuration.md: project extensions live in .pi/extensions/ and load "after project trust is granted".
  • Hermes 0.21.5. website/docs/user-guide/features/hooks.md and agent/shell_hooks.py: "A pre_tool_call hook that exits with code 2 blocks the tool call". Its payload is {"tool_name": "terminal", "tool_input": {"command": …}, "cwd": …}. Shell hooks are declared only in the profile's ~/.hermes/config.yaml, which this repo cannot carry. A project plugin under .hermes/plugins/ can register the same pre_tool_call, but hermes_cli/plugins_discovery.py scans it only with HERMES_ENABLE_PROJECT_PLUGINS set, and plugins also need plugins.enabled. git_guard.py would also need to accept tool_name: "terminal".

Limits and notes for review

  • A --mirror push can delete a remote main unseen. Git sends pre-push no record for a remote ref the clone lacks, so the hook never sees it. chore: block forbidden agent git commands with a PreToolUse hook #1158's command guard refuses --mirror, and GitHub's protection of main refuses the deletion. KnownLimitTest pins the current behaviour (exit 0, remote main deleted on a bare remote that allows it). If a future git sends the record, that test fails, and it should then become a refusal row.
  • --no-verify skips both hooks. Git offers no way to prevent that. chore: block forbidden agent git commands with a PreToolUse hook #1158's harness guard is the layer that sees the command line.
  • pre-commit does not run for merge commits. git merge on main creates one through pre-merge-commit, which this PR does not add. Rebase and cherry-pick onto main are likewise uncovered; the pre-push main check still stops the result from leaving the machine.
  • Wrapper-fed stdin (contrived). A record without its final newline, or a blank record, is skipped. Git always sends newline-terminated records, so only a broken or fabricated wrapper could hit this.
  • Linked worktrees: the guard reads git symbolic-ref --quiet HEAD. Under a hook, git exports the worktree's own GIT_DIR, so HEAD is the worktree's branch. The table proves this both ways.
  • CI: the table test runs in classifier-self-test (no needs, never gated) next to chore: block forbidden agent git commands with a PreToolUse hook #1158's guard test.

Tests run locally

  • python3 tools/agent-guard/git_hooks_test.py: 29 pushes against a fresh local bare repo each (10 allowed, 19 refused; every refusal checks that no remote ref changed), 10 commit cases, and the mirror known-limit test. All pass.
  • python3 tools/agent-guard/git_guard_test.py (chore: block forbidden agent git commands with a PreToolUse hook #1158's, after merging main): 161 commands × 2 payload shapes, all pass.
  • dotnet test filtered to TenancyDocsFreshnessTests and RenameAccountDocsTests (19 passed), plus the ImagePin and CommittedSchemaDocs_CarryNoEnvironmentSpecificContent tracked-file guards (3 passed).

Proof at a4ce288a

Mutation table

Each rule was disabled on its own, the table was run (red expected), then the hook was restored and the table re-run (green expected). Run at f0a59151; merging main changed neither hook nor the table test.

Hook Rule disabled Table with the mutation After revert
pre-commit commit on main red (2 failing) green
pre-commit unreadable branch refuses red (1 failing) green
pre-commit guard runs before SKIP_HOOKS (SKIP_HOOKS exit moved above the guard) red (1 failing) green
pre-commit hookTests=false skips tests red (1 failing) green
pre-push remote ref main red (5 failing) green
pre-push other deletions pass red (1 failing) green
pre-push pushed tag never moves red (2 failing) green
pre-push fast-forward only red (4 failing) green
pre-push HEAD resolves to branch red (1 failing) green
pre-push names must match red (6 failing) green
pre-push configured mapping exception red (4 failing) green
pre-push HEAD mapping only for checked-out branch red (1 failing) green
pre-push strip leading + red (1 failing) green
pre-push short names read as branches red (1 failing) green

Live refused push

core.hooksPath set to this branch's .githooks in a scratch clone of a fresh local bare repo (~/.cache/gh-hooks/live.py):

hooks: core.hooksPath=<worktree>/.githooks; remote: a fresh local bare repo

remote main before: d66341e 

$ git push origin feat/x:main
exit 1
pre-push: refusing refs/heads/feat/x -> refs/heads/main on origin: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then push that branch.
error: failed to push some refs to '/home/mforce/.tmp/tmpqx3_myg4/remote.git'

remote main after:  d66341e (unchanged)

$ git push -u origin feat/x
exit 0
To /home/mforce/.tmp/tmpqx3_myg4/remote.git
 * [new branch]      feat/x -> feat/x

remote feat/x after: cc46784 = local cc46784
Table test output at a4ce288a
known limit: git push --mirror from a clone without main -> exit 0 | remote main deleted
pre-commit allow  [feat/x]
pre-commit refuse [main]
                                                          pre-commit: refusing to commit on main: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then commit there.
pre-commit refuse [main, SKIP_HOOKS=1]
                                                          pre-commit: refusing to commit on main: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then commit there.
pre-commit allow  [detached HEAD]
pre-commit refuse [linked worktree on main]
                                                          pre-commit: refusing to commit on main: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then commit there.
pre-commit allow  [main checkout on feat/x beside it]
pre-commit allow  [linked worktree on feat/wt, main checkout on main]
pre-commit refuse [main checkout on main]
                                                          pre-commit: refusing to commit on main: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then commit there.
pre-commit allow  [feat/x, .cs staged, cluckwork.hookTests=false]
pre-commit refuse [feat/x, symbolic-ref exits 128]
                                                          pre-commit: refusing: cannot read the current branch (git symbolic-ref failed).
pre-push   allow  [feat/x                                  ] git push origin feat/x
pre-push   allow  [feat/x                                  ] git push -u origin HEAD
pre-push   allow  [pushed_then_ahead                       ] git push origin feat/x
pre-push   allow  [feat/x                                  ] git push origin v1
pre-push   allow  [feat/x                                  ] git push origin --delete feat/old
pre-push   allow  [paseo_remote                            ] git push paseo
pre-push   refuse [feat/x                                  ] git push origin main
                                                          pre-push: refusing refs/heads/main -> refs/heads/main on origin: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then push that branch.
pre-push   refuse [feat/x                                  ] git push origin feat/x:main
                                                          pre-push: refusing refs/heads/feat/x -> refs/heads/main on origin: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then push that branch.
pre-push   refuse [feat/x                                  ] git push origin HEAD:refs/heads/main
                                                          pre-push: refusing HEAD -> refs/heads/main on origin: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then push that branch.
pre-push   refuse [feat/x                                  ] git push origin --delete main
                                                          pre-push: refusing (delete) -> refs/heads/main on origin: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then push that branch.
pre-push   refuse [feat/x                                  ] git push origin :main
                                                          pre-push: refusing (delete) -> refs/heads/main on origin: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then push that branch.
pre-push   refuse [feat/x                                  ] git push origin feat/x:feat/y
                                                          pre-push: refusing refs/heads/feat/x -> refs/heads/feat/y on origin: the local and remote names differ, a src:dst push (#900). Run `git branch --show-current`, then `git push -u origin <that-branch>`.
pre-push   refuse [feat/x                                  ] git push origin HEAD:feat/y
                                                          pre-push: refusing refs/heads/feat/x -> refs/heads/feat/y on origin: the local and remote names differ, a src:dst push (#900). Run `git branch --show-current`, then `git push -u origin <that-branch>`.
pre-push   refuse [feat/x                                  ] git push origin HEAD~0:refs/heads/feat/x
                                                          pre-push: refusing HEAD~0 -> refs/heads/feat/x on origin: the local and remote names differ, a src:dst push (#900). Run `git branch --show-current`, then `git push -u origin <that-branch>`.
pre-push   refuse [paseo_remote                            ] git push paseo HEAD:refs/heads/feat/z
                                                          pre-push: refusing refs/heads/feat/y-1 -> refs/heads/feat/z on paseo: the local and remote names differ, a src:dst push (#900). Run `git branch --show-current`, then `git push -u origin <that-branch>`.
pre-push   allow  [paseo_pushed                            ] git push paseo
pre-push   refuse [paseo_pushed                            ] git push paseo feat/unrelated:refs/heads/feat/y
                                                          pre-push: refusing refs/heads/feat/unrelated -> refs/heads/feat/y on paseo: the local and remote names differ, a src:dst push (#900). Run `git branch --show-current`, then `git push -u origin <that-branch>`.
pre-push   allow  [push=feat/x:feat/y                      ] git push origin
pre-push   allow  [push=+refs/heads/feat/x:refs/heads/feat/y] git push origin
pre-push   refuse [push=refs/heads/feat/*:refs/heads/review/*] git push origin
                                                          pre-push: refusing refs/heads/feat/x -> refs/heads/review/x on origin: the local and remote names differ, a src:dst push (#900). Run `git branch --show-current`, then `git push -u origin <that-branch>`.
pre-push   refuse [rewritten_with_plus_mapping             ] git push origin
                                                          pre-push: refusing refs/heads/feat/x -> refs/heads/feat/x on origin: not a fast-forward (a force push, or the remote has commits you lack). Fetch, merge, and push a new commit.
pre-push   refuse [pushed_then_rewritten                   ] git push --force origin feat/x
                                                          pre-push: refusing refs/heads/feat/x -> refs/heads/feat/x on origin: not a fast-forward (a force push, or the remote has commits you lack). Fetch, merge, and push a new commit.
pre-push   refuse [pushed_then_rewritten                   ] git push origin +feat/x
                                                          pre-push: refusing refs/heads/feat/x -> refs/heads/feat/x on origin: not a fast-forward (a force push, or the remote has commits you lack). Fetch, merge, and push a new commit.
pre-push   refuse [pushed_then_rewritten                   ] git push --force-with-lease origin feat/x
                                                          pre-push: refusing refs/heads/feat/x -> refs/heads/feat/x on origin: not a fast-forward (a force push, or the remote has commits you lack). Fetch, merge, and push a new commit.
pre-push   refuse [on_main                                 ] git push origin HEAD
                                                          pre-push: refusing HEAD -> refs/heads/main on origin: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then push that branch.
pre-push   refuse [on_main                                 ] git push 
                                                          pre-push: refusing refs/heads/main -> refs/heads/main on origin: main changes only through a merged PR. Run `git switch -c <type>/<topic>`, then push that branch.
pre-push   refuse [tag_moved                               ] git push --force origin v1
                                                          pre-push: refusing refs/tags/v1 -> refs/tags/v1 on origin: a pushed tag is never moved or replaced, even when its commit moves forward. Push a new tag instead.
pre-push   refuse [annotated_tag_replaced                  ] git push --force origin v2
                                                          pre-push: refusing refs/tags/v2 -> refs/tags/v2 on origin: a pushed tag is never moved or replaced, even when its commit moves forward. Push a new tag instead.
pre-push   allow  [annotated_tag_replaced                  ] git push origin v3

pre-commit refuses a commit on main before any test step, in linked worktrees too. pre-push reads the refs git passes on stdin and refuses a push to main, a non-fast-forward update, and a push under another branch name unless remote.<name>.push maps it. cluckwork.hookTests=false turns the test steps off without turning off the guard.
@mforce mforce added this to the Platform hardening milestone Oct 10, 2026
@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 (Codex gpt-6-astra, head c926c469): 4 realistic findings, 2 contrived notes

Every finding below was reproduced with Git 2.53.0 against temporary local repositories and bare remotes.

  1. P1: git push --mirror can delete main without the hook seeing it. When the remote has main and the local repo does not, Git leaves the deletion out of pre-push stdin; the reviewer captured empty input while main was deleted. No hook-only fix exists. --mirror is refused at the command layer (chore: block forbidden agent git commands with a PreToolUse hook #1158). On GitHub, main is protected, and a protected branch cannot be deleted by push. Document the hook's limit and add the case to the table.
  2. P1: the configured HEAD:<dst> exception accepts any source. With remote.paseo.push=HEAD:refs/heads/feat/y, the command git push paseo feat/unrelated:refs/heads/feat/y passed and moved feat/y to an unrelated branch. Allow the mapping only when the pushed source is HEAD or the current branch.
  3. P2: forced tag replacement passes. git tag -f v1 HEAD && git push --force origin v1, and replacing an annotated tag at the same commit, both pass the ancestry check. Refuse any change to an existing refs/tags/*.
  4. P2: valid configured mappings are refused. remote.origin.push=feat/x:feat/y (short names) and a leading + are refused, while the same mapping written as full refs passes. Normalise names through Git before matching. If wildcard mappings stay unsupported, document that.

Contrived, kept as PR notes:

  • pre-commit treats any symbolic-ref failure as a detached HEAD.
  • A stdin record with no final newline is ignored.

Verified: removing any one of the four core checks turns the table red. Linked worktrees work, including with explicit GIT_DIR/GIT_WORK_TREE. The hooks run under dash, Bash in POSIX mode and BusyBox ash, with a median cost of about 5 ms per hook.

A tag update passes the commit-ancestry check when the tag moves to a descendant or is recreated at the same commit. Any change to an existing refs/tags/* now refuses before that check; new tags still pass. The table compares every remote ref around a refused push.
remote.<name>.push = HEAD:<dst> exempted any source pushed to <dst>, so git push paseo feat/unrelated:refs/heads/feat/y passed. The HEAD form now applies only when the pushed ref is the checked-out branch.
A remote.<name>.push mapping is matched after stripping a leading + (the history check still refuses a forced update) and reading short names as branches, so feat/x:feat/y maps like refs/heads/feat/x:refs/heads/feat/y. Wildcard refspecs stay unsupported and refuse.
git sends pre-push no record when --mirror deletes a remote ref the clone lacks, so a mirror push can delete main unseen. The hook header and AGENTS.md say so and name the layers that refuse it; a test pins the current behaviour so a git change that starts sending the record is noticed.
symbolic-ref status 1 is a detached HEAD; any other failure used to read as detached and let the commit through. It now refuses.
@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 (Codex gpt-6-astra, head c926c469): 4 realistic findings, 2 contrived notes (summary)

Head is now a4ce288a, which merges main (#1158). Each fix has table rows from the repro.

  1. Mirror deletion of main (P1), e41106ec. No hook fix exists: git sends no record for it. The pre-push header and AGENTS.md now say so and name the layers that refuse it: chore: block forbidden agent git commands with a PreToolUse hook #1158's command guard refuses --mirror, and GitHub's protection of main refuses the deletion. KnownLimitTest pins today's behaviour (exit 0 and remote main deleted, on a bare remote with receive.denyDeleteCurrent=ignore). If a future git sends the record, that test fails and should become a refusal row. The docs no longer claim the hook covers every main deletion.
  2. HEAD: mapping and unrelated sources (P1), 39ecb533. A configured HEAD:<dst> now applies only when the pushed ref is the checked-out branch. New row: git push paseo feat/unrelated:refs/heads/feat/y is a forward update to an existing destination, and it refuses. Plain git push paseo still passes.
  3. Tag replacement (P2), 654d9807. Any change to an existing refs/tags/* refuses before the ancestry check. Rows: a lightweight tag moved to a descendant and an annotated tag recreated at the same commit both refuse, and a new tag passes. Refused rows now compare every remote ref before and after.
  4. Configured refspec forms (P2), a6cd4f10. Configured refspecs drop a leading + (a forced update still meets the history check; new row rewritten_with_plus_mapping refuses), and short names read as branches. feat/x:feat/y and +refs/heads/feat/x:refs/heads/feat/y against an empty destination pass. Wildcards stay unsupported: they refuse, and the hook, AGENTS.md and PR body say so.
  5. Branch lookup errors (contrived), f0a59151. This cost three lines: symbolic-ref status 1 is a detached HEAD, and any other failure refuses. New test: a git shim whose symbolic-ref exits 128 refuses the commit.
  6. Unterminated or blank stdin records (contrived). Left as a PR note; git always sends newline-terminated records.

Mutation check: I disabled each of 14 rules one at a time. All 14 turned the table red and went green after revert. The first run of the "guard before SKIP_HOOKS" mutation deleted the SKIP_HOOKS line instead of moving it, so it proved nothing. Re-run as a move above the guard, it turns red. The table is in the PR body.

Table: 29 pushes (10 allowed, 19 refused, each refusal checking that no remote ref changed), 10 commit cases and the mirror known-limit test, all passing. After the merge, #1158's guard table also passes (161 × 2).

The maintainer's three answers are in the body: --force-with-lease refused, the Paseo exception limited to the checked-out branch, and Pi and Hermes moved to their own PR, so this one is now "Part of #1171". The body also carries the live refused push to a local bare repo.

@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Review round 2 (Codex gpt-6-astra, head a4ce288a): 1 realistic finding, 1 contrived note

All round 1 repros are closed or documented. The table passes (29 pushes, 10 commits, plus the mirror-limit case), and seven mutations turned it red. Normal --follow-tags and new-tag pushes pass.

  1. P2, realistic: a detached HEAD still gets the current-branch exception (.githooks/pre-push:28, :43, :44). With remote.paseo.push=HEAD:refs/heads/feat/y, the sequence git switch --detach, a commit, then git push paseo moved feat/y to the detached commit. That happens because the source HEAD matches current=HEAD. It also reproduces in a detached linked worktree. Fix: treat a detached HEAD as having no current branch, and require a resolved refs/heads/* branch for the exception. Add detached and detached-worktree rows, plus an attached positive control.

Contrived (PR note): pre-push still treats a branch-lookup error as allowed.

A detached HEAD set current to the literal HEAD, which matched a configured HEAD:<dst> mapping, so git push paseo published detached work. current is now empty when detached, the local ref stays HEAD, and the HEAD mapping needs the checked-out branch. A branch lookup that fails for any other reason refuses, as in pre-commit.
@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Review round 2 on head a4ce288a: 1 realistic finding, 1 contrived note (summary)

Fixed in 2bf09deb. Head is now 2bf09deb.

  1. Detached HEAD and the HEAD: mapping (P2). current is now empty on a detached HEAD, a detached local ref stays the literal HEAD, and a configured HEAD source matches only when the pushed ref equals the checked-out branch. Literal HEAD no longer matches by the generic same-source comparison. New rows: a detached checkout pushing to paseo refuses, a detached linked worktree pushing to paseo refuses (remote refs unchanged), and the attached git push paseo control still passes.
  2. Branch lookup errors in pre-push (contrived). This cost one line, so it is fixed rather than noted: status 1 means detached, and any other symbolic-ref failure refuses, as pre-commit does. The test calls the hook directly, with a Git-shaped stdin record and a git shim whose symbolic-ref exits 128. Through git push the shim never runs, because Git puts its exec-path first in a hook's PATH.

Mutation check on the new lines: three mutations, all red and all green again after revert: restoring round 2's generic HEAD match (2 failing), letting a detached local ref resolve to the empty current (2 failing), and letting a lookup failure pass (1 failing). A fourth guard I first added ([ -n "$current" ] in the HEAD branch) overlapped with the second and could not be shown red on its own, so I removed it.

Table: 30 pushes, the detached-worktree and lookup-failure tests, 10 commit cases and the mirror known-limit test, all passing.

@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Round 2 fix verified at 2bf09deb (coordinator check; no round 3)

  • python3 tools/agent-guard/git_hooks_test.py exits 0.
  • Live check with the hooks enabled, a local bare remote, and remote.paseo.push=HEAD:refs/heads/feat/y:
    • attached on feat/y-1: git push paseo passes and the remote feat/y advances (control);
    • detached commit, then git push paseo: refused ("the local and remote names differ"), and the remote feat/y is unchanged.
  • The delta is .githooks/pre-push (+9/−5) plus the new table rows. A lookup failure other than "detached" now refuses too.

The review loop stops here. Round 1 found 4 realistic defects and round 2 found 1; all are fixed or documented.

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.

1 participant