Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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()
        }
    }
}