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/gates/ai_trailers.rs

//! Gate 6. A commit NEW TO THE FORGE must not carry a machine-attribution
//! trailer (workspace LAW -1; owner ruling 2026-09-28).
//!
//! The matcher is [`crate::trailer`]; the policy, the default and the seed
//! exemption are documented on [`AiTrailerPolicy`]. What lives here is the
//! question "which commits does this push make new to the forge", answered as
//! `git rev-list <new> --not <every existing ref>` would answer it.
//!
//! # The walk
//!
//! A date-ordered two-colour walk, the shape git's own `--not --all` uses:
//! the pushed tip is painted NEW, every existing reference tip is painted OLD,
//! and commits are taken newest-first; each passes its paint to its parents.
//! The walk stops as soon as no NEW-only commit is left in the queue, so a
//! fast-forward, or a branch forked from last week's `main`, costs the commits
//! since the fork and not the repository.
//!
//! **OLD is always right, NEW may be pessimistic.** A commit is painted OLD
//! only by propagation from an existing tip, which is exact. Committer clocks
//! can be skewed, though, and a skewed ancestor can be popped as NEW before
//! the OLD paint reaches it. That error only ever REFUSES, never admits, and
//! the gate closes it for the commits that matter: before refusing, it keeps
//! walking the OLD side until every offending candidate is either reached
//! (old after all — allowed) or the OLD side is exhausted (genuinely new —
//! refused). That tail walk happens only on the refusal path.
//!
//! **The pack's object set is not used**, and [`Gate::reads_new_objects`] says
//! so, so a repository whose only live gate is this one does not make the
//! ingest enumerate objects it cannot (the gix engine refuses to).

use std::collections::{BinaryHeap, HashMap, HashSet};
use std::sync::Mutex;

use gix_hash::ObjectId;
use gunnar_store::git_storage_trait::ObjType;
use gunnar_store::object;

use crate::config::AiTrailerPolicy;
use crate::context::{PrincipalKind, PushContext, RefUpdate};
use crate::error::{Error, Result};
use crate::trailer::{ai_attribution_lines, AttributionLine};
use crate::{Gate, Verdict};

const NEW: u8 = 1;
const OLD: u8 = 2;

/// Refuses a push that introduces a commit carrying a machine-attribution
/// trailer.
#[derive(Debug)]
pub struct AiTrailerGate {
    policy: AiTrailerPolicy,
    /// **What this gate already judged clean, against which existing tips** (GUN-PERF,
    /// 2026-10-31). receive-pack asks once per reference, and an import pushes a
    /// thousand references over one history (git.git: 1 005 tags, 81 000 commits), so
    /// each walk re-read the history below it: 328 s of one push. A walk that allowed
    /// has judged every commit it reached (NEW-only ones clean, the rest old), so a
    /// later walk against the SAME existing tips stops where it meets one of them. A
    /// different set of existing tips (another push) starts the memory afresh.
    cleared: Mutex<Cleared>,
}

#[derive(Debug, Default)]
struct Cleared {
    existing: Vec<ObjectId>,
    commits: HashSet<ObjectId>,
}

impl Clone for AiTrailerGate {
    /// A copy judges afresh: the memory belongs to the pushes this one has seen.
    fn clone(&self) -> Self {
        AiTrailerGate::new(self.policy.clone())
    }
}

impl AiTrailerGate {
    /// A gate over `policy`.
    pub fn new(policy: AiTrailerPolicy) -> Self {
        AiTrailerGate {
            policy,
            cleared: Mutex::new(Cleared::default()),
        }
    }

    /// The policy this gate enforces.
    pub fn policy(&self) -> &AiTrailerPolicy {
        &self.policy
    }

    /// The seed exemption: the named MACHINE principal, into a repository with
    /// no references. Both halves, always.
    fn is_seed(&self, ctx: &PushContext<'_>, existing: &[ObjectId]) -> bool {
        match &self.policy.seed_principal {
            Some(seed) => {
                ctx.principal.kind == PrincipalKind::Machine
                    && ctx.principal.id == *seed
                    && existing.is_empty()
            }
            None => false,
        }
    }
}

impl Gate for AiTrailerGate {
    fn name(&self) -> &str {
        "ai-trailers"
    }

    fn reads_new_objects(&self) -> bool {
        false
    }

    fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict> {
        if !self.policy.refuse {
            return Ok(Verdict::Allow);
        }
        let existing = existing_tips(ctx)?;
        if self.is_seed(ctx, &existing) {
            tracing::info!(
                gate = self.name(),
                principal = %ctx.principal.id,
                repo = %ctx.repo,
                "seed push into an empty repository: history not judged for attribution trailers"
            );
            return Ok(Verdict::Allow);
        }
        // **One walk for the whole push** (GUN-PERF, 2026-10-31). The commits new to the
        // forge under several pushed tips are the union of what each tip brings, so one
        // walk from all of them against the existing tips finds every offender the
        // per-update walks would. Each update walked on its own re-read the history below
        // it: an import of git.git (1 005 tags over 81 000 commits) spent 328 s here.
        // An allow is answered by the one walk; a refusal (or a union that outgrows the
        // budget a single walk would not) goes back to the per-update walks, which name
        // the reference.
        if ctx.updates.len() > 1 {
            let mut tips = Vec::new();
            for update in ctx.updates {
                if let Some(new_tip) = update.new {
                    if let (Some(tip), _) = crate::ancestry::peel_to_commit(ctx.objects, &new_tip)?
                    {
                        tips.push(tip);
                    }
                }
            }
            match self.judge(ctx, &tips, &existing) {
                Ok(offenders) if offenders.is_empty() => return Ok(Verdict::Allow),
                Ok(_) | Err(Error::AncestryBudget { .. }) => {}
                Err(err) => return Err(err),
            }
        }
        for update in ctx.updates {
            if let Some(verdict) = self.check_update(ctx, update, &existing)? {
                return Ok(verdict);
            }
        }
        Ok(Verdict::Allow)
    }
}

impl AiTrailerGate {
    fn check_update(
        &self,
        ctx: &PushContext<'_>,
        update: &RefUpdate,
        existing: &[ObjectId],
    ) -> Result<Option<Verdict>> {
        let Some(new_tip) = update.new else {
            return Ok(None); // a delete introduces nothing
        };
        let (peeled, _tags) = crate::ancestry::peel_to_commit(ctx.objects, &new_tip)?;
        let Some(tip) = peeled else {
            return Ok(None); // a tag of a tree or a blob: no commits under it
        };
        let offenders = self.judge(ctx, &[tip], existing)?;
        let Some((first, lines)) = offenders.first() else {
            return Ok(None);
        };
        // Every offender carries at least one line (that is what made it one);
        // the fallback keeps an impossible empty list a refusal, not an allow.
        let (number, text) = lines
            .first()
            .map(|l| (l.line, l.text.as_str()))
            .unwrap_or((0, "?"));
        let mut reason = format!(
            "commit {first} carries an AI attribution trailer, line {}: \"{}\". This forge refuses \
             commits with machine-attribution trailers (Co-Authored-By naming Claude/Anthropic, \
             \"Generated with Claude\"). Remove the line and push again: `git commit --amend` if \
             {first} is the tip, otherwise `git rebase -i {first}^` and reword it.",
            number, text
        );
        if offenders.len() > 1 {
            let rest: Vec<String> = offenders[1..]
                .iter()
                .map(|(id, _)| id.to_string())
                .collect();
            reason.push_str(&format!(
                " {} more commit(s) in this push carry one too: {}.",
                rest.len(),
                rest.join(", ")
            ));
        }
        Ok(Some(Verdict::reject_ref(update.name.clone(), reason)))
    }
}

/// Every reference tip the repository holds right now, peeled to a commit
/// where it can be. Gates run before any reference in the push is applied, so
/// this is the repository as it was when the push arrived.
///
/// A row whose object is missing or undecodable is skipped rather than
/// failing the push: fewer OLD tips can only make the gate stricter, never
/// looser.
impl AiTrailerGate {
    /// [`new_commits_with_trailers`] through this gate's memory of cleared commits.
    fn judge(
        &self,
        ctx: &PushContext<'_>,
        tips: &[ObjectId],
        existing: &[ObjectId],
    ) -> Result<Vec<(ObjectId, Vec<AttributionLine>)>> {
        let mut sorted = existing.to_vec();
        sorted.sort_unstable();
        // A poisoned lock only loses the memory, never a judgement.
        let mut cleared = match self.cleared.lock() {
            Ok(c) => c,
            Err(poisoned) => poisoned.into_inner(),
        };
        if cleared.existing != sorted {
            cleared.existing = sorted;
            cleared.commits.clear();
        }
        new_commits_with_trailers(
            ctx,
            tips,
            existing,
            self.policy.budget,
            &mut cleared.commits,
        )
    }
}

fn existing_tips(ctx: &PushContext<'_>) -> Result<Vec<ObjectId>> {
    let rows = ctx.objects.refs().map_err(Error::backend)?;
    let mut tips = Vec::with_capacity(rows.len());
    for row in rows {
        let raw = match (row.peeled.as_deref(), row.oid.as_deref()) {
            (Some(peeled), _) => peeled,
            (None, Some(oid)) => oid,
            (None, None) => continue, // purely symbolic, e.g. HEAD
        };
        let Ok(id) = gix_hash::oid::try_from_bytes(raw) else {
            continue;
        };
        match crate::ancestry::peel_to_commit(ctx.objects, id) {
            Ok((Some(commit), _)) => tips.push(commit),
            Ok((None, _)) => {}
            Err(err) => tracing::warn!(
                reference = %row.name,
                %err,
                "an existing reference could not be peeled; not counted as forge history"
            ),
        }
    }
    tips.sort();
    tips.dedup();
    Ok(tips)
}

struct Node {
    flags: u8,
    /// The flags last passed on to the parents.
    passed: u8,
    /// Load order: the tip is 0, so a refusal names commits tip-first.
    order: u64,
    parents: Vec<ObjectId>,
    hits: Vec<AttributionLine>,
}

struct Walk<'c, 'a> {
    ctx: &'c PushContext<'a>,
    /// Commits an earlier allowing walk against the same existing tips reached: a NEW
    /// paint stops at one, since nothing below it is new and unjudged.
    cleared: &'c HashSet<ObjectId>,
    nodes: HashMap<ObjectId, Node>,
    queue: BinaryHeap<(i64, u64, ObjectId)>,
    seq: u64,
    budget: usize,
    from: ObjectId,
}

impl Walk<'_, '_> {
    /// Paint `id` with `flag`, loading it on first sight. A node whose paint
    /// grows is queued again so the new paint reaches its parents.
    fn paint(&mut self, id: ObjectId, flag: u8) -> Result<()> {
        if let Some(node) = self.nodes.get_mut(&id) {
            if node.flags & flag == flag {
                return Ok(());
            }
            node.flags |= flag;
            // Re-queued at "now" so it is taken promptly; its date only
            // orders the walk, it does not decide membership.
            self.seq += 1;
            self.queue.push((i64::MAX, self.seq, id));
            return Ok(());
        }
        if flag == NEW && self.cleared.contains(&id) {
            return Ok(());
        }
        if self.nodes.len() >= self.budget {
            return Err(Error::AncestryBudget {
                from: self.from,
                looking_for: id,
                walked: self.nodes.len(),
                budget: self.budget,
            });
        }
        let Some((kind, bytes)) = self
            .ctx
            .objects
            .read(id.as_bytes())
            .map_err(Error::backend)?
        else {
            return Err(Error::MissingObject {
                oid: id,
                what: "the commits new to the forge",
            });
        };
        if kind != ObjType::Commit {
            return Err(Error::decode(
                id,
                format_args!("expected a commit, found a {}", kind.as_str()),
            ));
        }
        let commit = object::commit(&bytes, id.kind()).map_err(|err| Error::decode(id, err))?;
        let hits = ai_attribution_lines(&commit.message);
        self.seq += 1;
        self.queue.push((commit.committer.seconds, self.seq, id));
        self.nodes.insert(
            id,
            Node {
                flags: flag,
                passed: 0,
                order: self.seq,
                parents: commit.parents,
                hits,
            },
        );
        Ok(())
    }

    /// Take one commit off the queue and pass its paint on.
    fn step(&mut self) -> Result<bool> {
        let Some((_, _, id)) = self.queue.pop() else {
            return Ok(false);
        };
        let (flags, parents) = {
            let node = self.nodes.get_mut(&id).expect("queued nodes are loaded");
            if node.passed == node.flags {
                return Ok(true);
            }
            node.passed = node.flags;
            (node.flags, node.parents.clone())
        };
        for parent in parents {
            self.paint(parent, flags)?;
        }
        Ok(true)
    }

    fn interesting_left(&self) -> bool {
        self.queue
            .iter()
            .any(|(_, _, id)| self.nodes.get(id).is_some_and(|n| n.flags == NEW))
    }

    /// NEW-only commits that carry a hit, in the order they were found.
    fn candidates(&self) -> Vec<ObjectId> {
        let mut out: Vec<(u64, ObjectId)> = self
            .nodes
            .iter()
            .filter(|(_, n)| n.flags == NEW && !n.hits.is_empty())
            .map(|(id, n)| (n.order, *id))
            .collect();
        out.sort();
        out.into_iter().map(|(_, id)| id).collect()
    }
}

/// The commits reachable from any of `tips` and from no `existing` tip that carry a
/// machine-attribution line, each with its lines.
fn new_commits_with_trailers(
    ctx: &PushContext<'_>,
    tips: &[ObjectId],
    existing: &[ObjectId],
    budget: usize,
    cleared: &mut HashSet<ObjectId>,
) -> Result<Vec<(ObjectId, Vec<AttributionLine>)>> {
    let Some(first) = tips.first() else {
        return Ok(Vec::new());
    };
    let mut walk = Walk {
        ctx,
        cleared,
        nodes: HashMap::new(),
        queue: BinaryHeap::new(),
        seq: 0,
        budget,
        from: *first,
    };
    for tip in tips {
        walk.paint(*tip, NEW)?;
    }
    for id in existing {
        walk.paint(*id, OLD)?;
    }
    while walk.interesting_left() {
        walk.step()?;
    }

    let mut suspects = walk.candidates();
    if suspects.is_empty() {
        // Allowed: every commit this walk reached is judged (the NEW-only ones clean,
        // the rest old), and a later walk of this push may stop at any of them.
        let judged: Vec<ObjectId> = walk
            .nodes
            .iter()
            .filter(|(_, n)| n.flags & NEW != 0)
            .map(|(id, _)| *id)
            .collect();
        cleared.extend(judged);
        return Ok(Vec::new());
    }
    // The refusal path: make sure each suspect is not old history reached
    // late because of a skewed clock. Walk the OLD side until every suspect
    // is painted OLD or there is nothing left to walk.
    loop {
        suspects.retain(|id| walk.nodes.get(id).is_some_and(|n| n.flags & OLD == 0));
        if suspects.is_empty() {
            return Ok(Vec::new());
        }
        match walk.step() {
            Ok(true) => {}
            Ok(false) => break,
            // A walk that cannot finish cannot clear a suspect. The suspect
            // stays a suspect, which refuses; it does not become an allow.
            Err(err) => {
                tracing::warn!(%err, "could not finish the old-history walk; suspects stay refused");
                break;
            }
        }
    }
    Ok(suspects
        .into_iter()
        .map(|id| {
            let hits = walk
                .nodes
                .get(&id)
                .map(|n| n.hits.clone())
                .unwrap_or_default();
            (id, hits)
        })
        .collect())
}