The pre-receive gate — design
Class:
design· Function axis:f0the gate ·f1theGatetrait and the engine ·f2branch protection ·f3ref-name policy ·f4ssh signatures ·f5size and count limits. Strict: this file states whatgunnar-policyIS. Where it sits in the push: wire-design.md. What Gunnar is: design.md.
gunnar-policy is what makes Gunnar a forge rather than a bare repository over SSH. It runs between "the pack is indexed" and "the references are applied", and it can say no.
receive-pack: read command list → read pack → index → verify connectivity
│
├─▶ gunnar-policy ──▶ Allow → apply the ref transaction
│ └─▶ Reject(…) → report-status, no refs move
Status, 2026-08-03: built. All four v1 gates are implemented and each is asserted to reject a crafted bad push as well as to allow a good one.
The trait, and what a gate gets to see
pub trait Gate: Send + Sync {
fn name(&self) -> &str;
fn check(&self, ctx: &PushContext) -> Result<Verdict>; // Allow | Reject(reason)
}
PushContext carries the principal, the repository, every RefUpdate { old, new, name }, the set of newly introduced objects, the measured pack size, and an ObjectStore handle to read them.
Objects, not commits. A size gate has to see blobs, and the commit set is derived (new_commits()) rather than stored alongside, so the two cannot disagree. new_objects is a required constructor argument, never a builder option with a default: an empty object set is a silent always-allow switch, and no test that supplies the field could notice it was optional.
Everything in the context is borrowed. receive-pack owns the pack index, the command list and the principal for the life of the push. Borrowing is also what makes PushContext::narrowed_to free, which is how the engine reports which reference a gate objected to without changing the Gate signature: on the refusal path only, the engine re-runs the gate against each single-ref sub-context. Verdict::Rejection then carries a ref_name, which both PushRejected{repo, ref, gate, reason} and report-status-v2's per-ref ng lines need.
Key material lives in a KeyDirectory rather than on the principal, because the signature gate has to ask about keys belonging to other principals. A commit authored by someone else and pushed by you is the normal case.
An error is not a rejection
A Verdict::Reject is a policy answer: the push was well-formed and Gunnar has decided not to take it. An Error is a Gunnar answer: the gate could not reach a decision, because an object was missing, a pattern was malformed, or a walk ran out of budget.
Conflating them is how a broken gate turns into a silent allow. So every gate propagates its error, and PolicyEngine::evaluate treats an erroring gate as a rejection with the error as its reason, never as an allow. Inconclusive never allows.
The four gates of v1
| gate | refuses |
BranchProtectionGate |
a force-push, a delete, an unsigned commit or an unauthorised pusher on a protected reference |
RefNameGate |
a reference name the namespace has not sanctioned |
SignedCommitsGate |
a commit without a verifying gpg.format=ssh signature |
LimitsGate |
an oversized object, an absurd object count, or an oversized pack |
Branch protection rests on a real ancestry walk
old != new is not a force-push test. Neither is comparing hex strings, nor "is old in the set of newly-pushed commits", nor checking that new is reachable from some reference. The only honest answer is a walk of the commit graph, and a walk that cannot finish must say so rather than shrug.
Merges are why this is not a three-line function. On a linear history, "walk parents until you find it" is trivially right and a first-parent-only walk is indistinguishable from a full one. On a merge it is not: the old tip is very often reachable only through a merge's second parent, so a first-parent walk reports a legitimate fast-forward as a force-push. The fixtures are therefore built with real merges, and one of them puts the old tip exclusively on a second-parent path.
The walk is O(commits between the two tips), bounded by a per-rule budget (200000 by default) that is large enough that no honest push reaches it and small enough that a crafted one cannot make a gate run for minutes. Exhausting the budget is an error, which the engine turns into a rejection.
__gunnar_graph__ exists to make this O(1) in the common case: generation(old) > generation(new) disproves descent without reading a single object. is_descendant_of is a pure function of an oid pair and a store, so the accelerated version substitutes for it without touching a gate. Not built.
One pattern matcher, used by both gates
Two matchers is how refs/heads/* comes to mean one thing in the allowlist and another in the protection rule, and nobody notices until a protected branch is force-pushed. So there is one, over bytes, because reference names are bytes.
| token | matches |
* |
any run of bytes, not crossing / |
** |
any run of bytes, including / |
? |
exactly one byte, not / |
| anything else | itself, literally |
refs/heads/* matches refs/heads/main but not refs/heads/feat/login; refs/heads/** matches both. That distinction is the whole reason ** exists, and it is pinned in both directions.
Signatures are verified in pure Rust
No gpg, no ssh-keygen, no subprocess, no C — the server has no shell to run one in. Everything is ssh-key and byte handling.
With gpg.format=ssh, git commit -S puts an armored SSHSIG blob in a gpgsig header of the commit object, folded the way git folds every multi-line header: continuation lines get one leading space.
The signed payload is the commit object with that header removed. Not the commit id, not the message, not the commit as stored. Getting it wrong fails every signature, which at least fails loudly. Get it wrong in the forgiving direction and every signature passes: verify the stored bytes, fall back to "well, it had a signature header", and you have the always-allow failure mode wearing a beard. So the payload is reconstructed by removing exactly the header span that carried the signature, and the reconstruction is checked against signatures produced by stock git 2.53 with ssh-keygen, not only against signatures this crate minted itself.
Both gpgsig and gpgsig-sha256 are accepted. Git writes the latter in sha1/sha256 interoperability repositories, and reading only gpgsig would make such a commit look unsigned: merely loud under a required-signature policy, silently wrong under branch protection.
SSHSIG signatures are namespaced so a signature minted for one purpose cannot be replayed for another. Git uses git. A blob with any other namespace is refused, and a test asserts that a file-namespaced signature over byte-identical content does not verify.
Limits, and an honest note about "before it lands"
The specification puts this gate as "reject a 4 GB blob before it lands, not after". By the time any gate runs, receive-pack has already read the pack off the wire and indexed it. So this gate rejects before the references are applied and before the objects are folded into the cold tier — not before the bytes arrive.
The only place an oversized push can be refused before it is received is the pack reader itself, streaming, and that ceiling belongs in gunnar-pack. LimitPolicy::max_pack_bytes is the seam: receive-pack passes the measured pack size in PushContext::pack_bytes, and if the limit is configured but the size was not measured the gate rejects rather than passing. A limit that cannot be enforced is not a satisfied limit.
Sizes come from ObjectStore::header, never from reading payloads. Refusing an oversized blob must not require decompressing it.
Configuration
Hooks are in-process Gate implementations, not shell scripts. What replaces a pre-receive hook is PolicyConfig: a serde document naming the gates and their parameters. An out-of-process hook, if one is ever wanted, is a gRPC call to a gate service, never fork/exec.
pub struct PolicyConfig {
pub ref_names: RefNamePolicy, // which reference names may exist at all
pub protection: Vec<ProtectionRule>,// per-pattern branch protection
pub signatures: SignaturePolicy, // commit signing
pub limits: LimitPolicy, // size and count ceilings
}
Every default is chosen so that an under-specified policy is the safe one. A protection rule that names only a pattern refuses force-pushes and deletes. A limit that is not set is None and is reported as unenforced rather than as satisfied. The document is deny_unknown_fields, so a typo in a rule name is an error rather than a rule that silently does nothing.
gunnar-core has no storage for protection rules yet, so the document is read from gunnar-policy.json beside the repository. When the catalog grows a policy document, that constant is the only thing that changes, and the gRPC Policy service stops answering Unimplemented.
Verification
LAW 2 applies harder here than anywhere else in Gunnar:
A gate that always returns
Allowis indistinguishable from a working gate in any test that only pushes valid commits.
Everything is arranged around that sentence.
-
Every gate has a paired test. One push that must be refused with a named reason, and one that must be allowed. Never one direction alone.
-
The harness is proved able to report red.
gunnar_policy::blindholds an always-allow stand-in, in the library rather than intests/, andtests/blind.rsruns the same crafted-bad-push fixtures through it and asserts the harness fails. The usual approach — comment a gate out, watch the test fail, put it back — proves the assertion could fail on the day somebody did it, and nothing thereafter. This is permanent. Nothing inblindis reachable fromPolicyConfig, so a configuration cannot select one by accident. -
The division of labour was measured, not assumed, by mutating the crate and watching which test went red:
| mutation | caught by |
| a gate silently becomes always-allow | tests/gates.rs |
| a fixture stops being a genuinely bad push | tests/gates.rs, since the real gate allows it |
| the assertion itself can no longer fail | tests/blind.rs, and nothing else |
That last row is why the module exists. A harness whose expect_reject had been softened into always returning Ok leaves every rejection test in the crate green and meaningless, and tests/gates.rs cannot see it because it is the thing being deceived.
-
Signature verification is checked against stock
git. The tests drivegitwithgpg.format=sshto produce genuinely signed commits and verify them with this crate's pure-Rust path. Gunnar never runs git; its tests do, because two implementations agreeing is evidence and one implementation agreeing with itself is not. -
The force-push check runs over a fixture containing a merge, because a first-parent walk and a full one are indistinguishable on a linear three-commit history.
The no-OS rule
gunnar-policy is a server crate. No filesystem, no shell, no subprocess, no gpg, no ssh-keygen. Signature verification is ssh-key and byte handling; everything else is the ObjectStore trait. gix appears only as a dev-dependency, for fixtures, which is why the dependency guard has to scope to normal edges, and why it must assert on a resolved graph for the appliance feature set rather than on declared features. Under resolver 2 a workspace build unifies gunnar-client's gix features into the server crates' gix, so a guard reading declarations passes while the actual build has already dragged worktree features across.
Source map
- the trait, the engine and
Verdict→crates/gunnar-policy/src/lib.rs - the four gates →
crates/gunnar-policy/src/gates/ - ancestry →
crates/gunnar-policy/src/ancestry.rs - SSHSIG →
crates/gunnar-policy/src/signature.rs - the pattern matcher →
crates/gunnar-policy/src/pattern.rs - the always-allow stand-in →
crates/gunnar-policy/src/blind.rs - the wiring into receive-pack →
crates/gunnar-cli/src/server/transport.rs
See also: design.md · wire-design.md · TOTAL-PROMPT.md §7.