crates/gunnar-policy/src/config.rs
//! The declarative policy: what the gates are configured *with*.
//!
//! **Hooks are in-process `Gate` impls, not shell scripts.** There is no
//! shell on the server to run one in, and there will not be one. What replaces a
//! `pre-receive` hook is this: a serde document, stored per repository or per
//! namespace, that names 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`.
//!
//! Every default here 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.
use serde::{Deserialize, Serialize};
use crate::ancestry::DEFAULT_ANCESTRY_BUDGET;
use crate::error::{Error, Result};
use crate::pattern::RefPattern;
use crate::signature::GIT_NAMESPACE;
use crate::Gate;
/// The whole policy for one repository.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PolicyConfig {
/// Which reference names may exist at all.
pub ref_names: RefNamePolicy,
/// Per-pattern branch protection.
pub protection: Vec<ProtectionRule>,
/// Commit signing.
pub signatures: SignaturePolicy,
/// Size and count ceilings.
pub limits: LimitPolicy,
/// Which `git push -o …` options this repository accepts, and demands.
pub push_options: PushOptionPolicy,
/// Refuse commits carrying a machine-attribution trailer. **On unless a
/// document turns it off**: a repository with no stored policy, and a
/// stored document written before this field existed, both get it.
pub ai_trailers: AiTrailerPolicy,
/// How `PullRequests.Merge` may merge (GUN-MERGE-ENGINE, 2026-11-01): a
/// fast-forward and a merge commit by default, a squash only when turned on.
pub merge: MergePolicy,
}
/// **Which merge strategies a repository's pull requests may use**
/// (GUN-MERGE-ENGINE, 2026-11-01).
///
/// The default is the merge every forge offers: a fast-forward where the head
/// descends from the target, a merge commit (two parents) where it does not.
/// A squash rewrites the reviewed commits into one, so it is off until the
/// repository's policy turns it on. `allow_merge_commit: false` is a
/// "linear history" repository: a branch that fell behind is refused and
/// rebased by its author.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MergePolicy {
/// Move the target to the head when the head descends from it.
pub allow_fast_forward: bool,
/// Write a merge commit (target tip and head as parents).
pub allow_merge_commit: bool,
/// Write one commit of the merged tree on the target tip.
pub allow_squash: bool,
}
impl Default for MergePolicy {
fn default() -> Self {
MergePolicy {
allow_fast_forward: true,
allow_merge_commit: true,
allow_squash: false,
}
}
}
impl PolicyConfig {
/// Parse from JSON.
pub fn from_json(json: &str) -> Result<Self> {
serde_json::from_str(json).map_err(|err| Error::Config(err.to_string()))
}
/// Render to JSON.
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(|err| Error::Config(err.to_string()))
}
/// The gates this configuration asks for, in the order they run.
///
/// Only gates that would actually decide something are built: an empty
/// ref-name policy contributes no gate at all, rather than a gate that always
/// says yes. The difference is visible in
/// [`PolicyEngine::gate_names`](crate::PolicyEngine::gate_names), so
/// "which gates are live on this repository" is answerable. That is the
/// control plane's `Policy.ListGates`.
pub fn gates(&self) -> Vec<Box<dyn Gate>> {
let mut gates: Vec<Box<dyn Gate>> = Vec::new();
if !self.ref_names.is_empty() {
gates.push(Box::new(crate::gates::RefNameGate::new(
self.ref_names.clone(),
)));
}
if !self.protection.is_empty() {
gates.push(Box::new(crate::gates::BranchProtectionGate::new(
self.protection.clone(),
)));
}
if self.signatures.required {
gates.push(Box::new(crate::gates::SignedCommitsGate::new(
self.signatures.clone(),
)));
}
if !self.limits.is_empty() {
gates.push(Box::new(crate::gates::LimitsGate::new(self.limits)));
}
if !self.push_options.is_empty() {
gates.push(Box::new(crate::gates::PushOptionsGate::new(
self.push_options.clone(),
)));
}
if self.ai_trailers.refuse {
gates.push(Box::new(crate::gates::AiTrailerGate::new(
self.ai_trailers.clone(),
)));
}
gates
}
}
/// Which reference names are allowed to exist.
///
/// `deny` is evaluated first and wins, because a denylist entry is the thing an
/// administrator adds in a hurry and it must not be quietly out-voted by a broad
/// allow pattern.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RefNamePolicy {
/// If non-empty, a name must match one of these.
///
/// **Empty means "no allowlist", not "allow nothing".** A policy nobody
/// configured must not reject every push, and a policy somebody configured
/// must not be defeated by a typo that empties it, so the empty case is
/// documented, tested in both directions, and visible through
/// [`RefNamePolicy::is_empty`].
pub allow: Vec<RefPattern>,
/// A name matching any of these is refused, allowlist or not.
pub deny: Vec<RefPattern>,
}
impl RefNamePolicy {
/// `true` if this policy would never refuse anything.
pub fn is_empty(&self) -> bool {
self.allow.is_empty() && self.deny.is_empty()
}
/// Build from string patterns.
pub fn new<A, D>(allow: A, deny: D) -> Result<Self>
where
A: IntoIterator,
A::Item: Into<String>,
D: IntoIterator,
D::Item: Into<String>,
{
Ok(RefNamePolicy {
allow: allow
.into_iter()
.map(RefPattern::new)
.collect::<Result<Vec<_>>>()?,
deny: deny
.into_iter()
.map(RefPattern::new)
.collect::<Result<Vec<_>>>()?,
})
}
}
/// Protection for every reference matching one pattern.
///
/// The first rule whose pattern matches wins; later rules do not also apply. That
/// makes a specific rule for `refs/heads/main` followed by a broad one for
/// `refs/heads/**` behave the way it reads.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProtectionRule {
/// Which references this rule covers.
pub pattern: RefPattern,
/// Permit a new tip that is not a descendant of the old one.
#[serde(default)]
pub allow_force_push: bool,
/// Permit removing the reference.
#[serde(default)]
pub allow_delete: bool,
/// Permit creating a reference that matches the pattern.
///
/// Defaults to `true`: a rule protecting `refs/heads/**` is about the
/// branches that exist, and defaulting this to `false` would make such a rule
/// forbid all new branches, which nobody means.
#[serde(default = "yes")]
pub allow_create: bool,
/// Every commit this push adds under a matching reference must carry a
/// verifying ssh signature.
#[serde(default)]
pub require_signature: bool,
/// If set, only these principal ids may move a matching reference.
#[serde(default)]
pub allowed_principals: Option<Vec<String>>,
/// The merge gate must not count the pull request AUTHOR's own approval
/// when the target matches this rule.
///
/// A merge-gate field, not a push gate: `PullRequests.Merge` reads it, and
/// [`crate::gates::BranchProtectionGate`] deliberately does not — a push is
/// not a merge, and a rule about reviews has nothing to say to
/// receive-pack. Default `false`, because a solo operator's appliance with
/// a review rule nobody can satisfy is a locked door with no key.
#[serde(default)]
pub forbid_self_approval: bool,
/// How many DISTINCT verified approvers of the exact source tip
/// `PullRequests.Merge` needs before it moves a matching reference.
///
/// `0` is the baseline that always holds anyway — one verified approval —
/// so an unset field changes nothing. Approvals by the same principal
/// count once: the fold already keys approvals by actor, and this field is
/// a floor on that map's size, never on the log's length.
#[serde(default)]
pub required_approvals: u32,
/// How many commits an ancestry check may visit before declaring the question
/// undecided, and refusing.
#[serde(default = "default_budget")]
pub ancestry_budget: usize,
}
fn yes() -> bool {
true
}
fn default_budget() -> usize {
DEFAULT_ANCESTRY_BUDGET
}
impl ProtectionRule {
/// A maximally protective rule for `pattern`: no force-push, no delete.
pub fn protect(pattern: &str) -> Result<Self> {
Ok(ProtectionRule {
pattern: RefPattern::new(pattern)?,
allow_force_push: false,
allow_delete: false,
allow_create: true,
require_signature: false,
allowed_principals: None,
forbid_self_approval: false,
required_approvals: 0,
ancestry_budget: DEFAULT_ANCESTRY_BUDGET,
})
}
/// Also require every added commit to be signed.
pub fn requiring_signature(mut self) -> Self {
self.require_signature = true;
self
}
/// Also restrict who may push.
pub fn by<I: IntoIterator<Item: Into<String>>>(mut self, principals: I) -> Self {
self.allowed_principals = Some(principals.into_iter().map(Into::into).collect());
self
}
}
/// Which keys a commit signature may come from.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignerScope {
/// The signature must be by a key registered to **any** principal gunnar
/// knows. This is the useful default: a commit written by a colleague and
/// pushed by you is ordinary.
#[default]
AnyRegisteredKey,
/// The signature must be by a key registered to the principal doing the
/// pushing. Strict, and correct for a deploy key or a release branch.
PusherKeysOnly,
}
/// Commit-signing policy.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SignaturePolicy {
/// Require a verifying signature on every commit the push adds.
pub required: bool,
/// Whose keys count.
pub scope: SignerScope,
/// The SSHSIG namespace. Git uses `git`; changing it is for testing what
/// happens when a signature is replayed from another namespace.
pub namespace: String,
/// Walk budget, as in [`ProtectionRule::ancestry_budget`].
pub budget: usize,
}
impl Default for SignaturePolicy {
fn default() -> Self {
SignaturePolicy {
required: false,
scope: SignerScope::default(),
namespace: GIT_NAMESPACE.to_owned(),
budget: DEFAULT_ANCESTRY_BUDGET,
}
}
}
impl SignaturePolicy {
/// Require signatures, from any key gunnar knows.
pub fn required() -> Self {
SignaturePolicy {
required: true,
..Default::default()
}
}
/// Require signatures from the pushing principal's own keys.
pub fn required_from_pusher() -> Self {
SignaturePolicy {
required: true,
scope: SignerScope::PusherKeysOnly,
..Default::default()
}
}
}
/// Size and count ceilings, applied to what the pack introduced.
///
/// Every field is `Option` on purpose. `None` is "not configured", and a gate
/// reports it as unenforced rather than as satisfied. A limit of `0` and a limit
/// of "unset" must never be the same value, which is the identity-value trap this
/// gate is most exposed to.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LimitPolicy {
/// Largest permitted single object of any kind, in bytes.
pub max_object_bytes: Option<u64>,
/// Largest permitted blob, in bytes. Applied in addition to
/// `max_object_bytes`, so a repository can allow a big tree but not a big
/// file.
pub max_blob_bytes: Option<u64>,
/// Most objects one push may introduce.
pub max_objects: Option<u64>,
/// Largest permitted sum of introduced object sizes, in bytes.
pub max_total_bytes: Option<u64>,
/// Largest permitted packfile on the wire, in bytes.
///
/// Requires [`PushContext::pack_bytes`](crate::PushContext::pack_bytes). If
/// the caller did not measure it, the gate **rejects** rather than passing:
/// an unmeasurable limit is not a satisfied one.
pub max_pack_bytes: Option<u64>,
}
impl LimitPolicy {
/// `true` if nothing is configured.
pub fn is_empty(&self) -> bool {
*self == LimitPolicy::default()
}
/// A ceiling on any single object.
pub fn object_bytes(bytes: u64) -> Self {
LimitPolicy {
max_object_bytes: Some(bytes),
..Default::default()
}
}
/// A ceiling on how many objects one push may introduce.
pub fn objects(count: u64) -> Self {
LimitPolicy {
max_objects: Some(count),
..Default::default()
}
}
}
/// Which `git push -o …` options this repository accepts, and which it demands.
///
/// # Why an empty policy is "not configured" and not "allow nothing"
///
/// Same rule as [`RefNamePolicy`]: a repository nobody has configured must not
/// start refusing every push, and `PolicyConfig::gates` builds no
/// [`PushOptionsGate`](crate::gates::PushOptionsGate) at all for an empty one —
/// rather than a gate that always says yes, which would be indistinguishable
/// from a broken gate in every test that pushes a valid commit.
///
/// The patterns are [`RefPattern`] globs, the same matcher the ref-name and
/// protection gates use (one implementation): `ci.*`, `reviewer=*`, `ticket=NORD-???`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PushOptionPolicy {
/// If non-empty, every option the client sends must match one of these.
///
/// This is the rule that turns "the server ignored your `-o`" into a
/// refusal the client can read.
pub allowed: Vec<RefPattern>,
/// Every one of these must be matched by some option the client sent.
///
/// A push carrying no options at all fails every entry here, which is the
/// intent: `-o ticket=…` is not optional if it is required.
pub required: Vec<RefPattern>,
}
impl PushOptionPolicy {
/// `true` if this policy would never refuse anything.
pub fn is_empty(&self) -> bool {
self.allowed.is_empty() && self.required.is_empty()
}
/// Build from string patterns.
pub fn new<A, R>(allowed: A, required: R) -> Result<Self>
where
A: IntoIterator,
A::Item: Into<String>,
R: IntoIterator,
R::Item: Into<String>,
{
Ok(PushOptionPolicy {
allowed: allowed
.into_iter()
.map(RefPattern::new)
.collect::<Result<Vec<_>>>()?,
required: required
.into_iter()
.map(RefPattern::new)
.collect::<Result<Vec<_>>>()?,
})
}
}
/// The principal id the installer's seed pushes as, and the only one the
/// seed exemption of [`AiTrailerPolicy`] can ever apply to.
pub const DEFAULT_SEED_PRINCIPAL: &str = "installer-seed";
/// Refusing machine-attribution trailers (workspace LAW -1; owner ruling
/// 2026-09-28: "yes block AI trailers").
///
/// # Default ON, which is the opposite of every other policy here
///
/// Every other section of [`PolicyConfig`] is empty by default and builds no
/// gate. This one is **on** by default, because the rule it enforces is the
/// house's and not a repository's choice: a repository nobody configured is
/// covered. An administrator turns it off for one repository by storing a
/// document with `"ai_trailers": {"refuse": false}`.
///
/// # What is judged: history NEW TO THE FORGE, not the pack
///
/// Only commits reachable from the pushed tip and **not reachable from any
/// reference the repository already has** — every branch, tag and hidden ref,
/// not only the one being moved. The house carries hundreds of historical
/// trailer commits; pushing an existing one to a new branch name is not
/// introducing it. The pack's new-object set is deliberately NOT the measure:
/// a refused push leaves its objects in the store, and "not new in this pack"
/// would wave the same commit through on the second attempt.
///
/// # The seed exemption, and why it cannot be borrowed by a user
///
/// A reinstalled forge is empty and is re-seeded from the mirrors, whose
/// history carries those historical trailers. Pushed into an empty repository
/// the whole history is new to the forge, so without an exemption the seed
/// would be refused. The exemption is **a principal and a phase, both
/// checked**:
///
/// * the pusher is a **machine** principal ([`PrincipalKind::Machine`]) whose
/// id is exactly [`AiTrailerPolicy::seed_principal`] — a user principal can
/// never match, whatever its id, and a machine principal exists only if a
/// server administrator enrolled it;
/// * the repository has **no references at all** when the push arrives — the
/// seed phase. From the first reference on, the seed principal's own pushes
/// are judged like anybody else's.
///
/// `seed_principal: null` removes the exemption for a repository.
///
/// [`PrincipalKind::Machine`]: crate::PrincipalKind::Machine
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AiTrailerPolicy {
/// Refuse. `true` by default.
pub refuse: bool,
/// The machine principal whose push into an EMPTY repository is not
/// judged. See the type's docs.
pub seed_principal: Option<String>,
/// How many commits the new-to-the-forge walk may load before the gate
/// declares itself undecided, and refuses.
pub budget: usize,
}
impl Default for AiTrailerPolicy {
fn default() -> Self {
AiTrailerPolicy {
refuse: true,
seed_principal: Some(DEFAULT_SEED_PRINCIPAL.to_owned()),
budget: DEFAULT_ANCESTRY_BUDGET,
}
}
}
impl AiTrailerPolicy {
/// The gate off: what an administrator stores to exempt one repository.
pub fn off() -> Self {
AiTrailerPolicy {
refuse: false,
..Default::default()
}
}
}