crates/gunnar-policy/tests/ai_trailers.rs
//! The attribution-trailer gate, both directions (owner ruling 2026-09-28:
//! "yes block AI trailers").
//!
//! What is pinned here, one test each:
//!
//! * a commit new to the forge carrying a trailer is refused, and the refusal
//! names the commit's full sha, the offending line, and the fix;
//! * a historical trailer commit already reachable from ANOTHER branch is
//! accepted when pushed to a new branch name;
//! * a message that merely mentions the tool in prose passes;
//! * the seed — the `installer-seed` MACHINE principal pushing into an EMPTY
//! repository — passes with historical trailers, while a user pushing the
//! same history into an empty repository is refused;
//! * the exemption cannot be borrowed: not by a user with the seed's id, not
//! by another machine, not by the seed itself once the repository has a
//! reference;
//! * "not new in this pack" is not "old": a commit a refused push left in the
//! store is refused again;
//! * a skewed committer clock cannot turn old history into a refusal.
mod common;
use common::{expect_allow, expect_reject, refname, Repo, Scenario};
use gunnar_policy::trailer::is_ai_attribution;
use gunnar_policy::{
AiTrailerGate, AiTrailerPolicy, Gate, PolicyConfig, PolicyEngine, Principal, RefUpdate,
};
const TRAILER: &str = "Co-Authored-By: Claude <noreply@anthropic.com>";
fn gate() -> AiTrailerGate {
AiTrailerGate::new(AiTrailerPolicy::default())
}
fn with_trailer(subject: &str) -> String {
format!("{subject}\n\nA body line.\n\n{TRAILER}")
}
#[test]
fn a_new_trailer_commit_is_refused_and_the_refusal_names_the_sha_the_line_and_the_fix() {
let repo = Repo::sha1();
let base = repo.commit("base", &[]);
repo.set_ref("refs/heads/main", base);
let clean = repo.commit("clean work", &[base]);
let bad = repo.commit(&with_trailer("bad work"), &[clean]);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::update(refname("refs/heads/main"), base, bad))
.introducing([clean, bad]);
let verdict = expect_reject(&gate(), &scenario.context(), &bad.to_string()).unwrap();
let reason = verdict.reason().unwrap();
assert!(reason.contains(TRAILER), "the line is not quoted: {reason}");
assert!(
reason.contains("line 5"),
"the line number is missing: {reason}"
);
assert!(
reason.contains("git commit --amend"),
"no fix offered: {reason}"
);
assert!(
reason.contains(&format!("git rebase -i {bad}^")),
"{reason}"
);
assert!(
!reason.contains(&clean.to_string()),
"the clean commit is blamed: {reason}"
);
assert_eq!(verdict.ref_name(), Some("refs/heads/main"));
}
#[test]
fn every_offender_in_one_push_is_named_tip_first() {
let repo = Repo::sha1();
let base = repo.commit("base", &[]);
repo.set_ref("refs/heads/main", base);
let first = repo.commit(&with_trailer("first"), &[base]);
let second = repo.commit("Generated with Claude Code\n\nmore", &[first]);
let scenario = Scenario::new(repo.objects.clone()).pushing(RefUpdate::update(
refname("refs/heads/main"),
base,
second,
));
let verdict = expect_reject(&gate(), &scenario.context(), &second.to_string()).unwrap();
let reason = verdict.reason().unwrap();
assert!(reason.starts_with(&format!("commit {second}")), "{reason}");
assert!(
reason.contains(&format!(
"1 more commit(s) in this push carry one too: {first}"
)),
"{reason}"
);
}
#[test]
fn an_old_trailer_commit_on_another_branch_is_accepted_on_a_new_branch() {
let repo = Repo::sha1();
let base = repo.commit("base", &[]);
let old = repo.commit(&with_trailer("historical"), &[base]);
repo.set_ref("refs/heads/main", base);
repo.set_ref("refs/heads/lane/2026-08", old);
// The historical commit itself, at a new name.
let same = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/again"), old));
expect_allow(&gate(), &same.context()).unwrap();
// New clean work on top of it: only the new commit is judged.
let on_top = repo.commit("clean follow-up", &[old]);
let follow = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/follow"), on_top))
.introducing([on_top]);
expect_allow(&gate(), &follow.context()).unwrap();
// And moving `main` onto it — a fast-forward over history the forge holds.
let ff = Scenario::new(repo.objects.clone()).pushing(RefUpdate::update(
refname("refs/heads/main"),
base,
old,
));
expect_allow(&gate(), &ff.context()).unwrap();
}
#[test]
fn a_tag_holding_old_history_counts_as_forge_history() {
let repo = Repo::sha1();
let old = repo.commit(&with_trailer("released"), &[]);
let tag = repo.tag("v1", old, gix_object::Kind::Commit);
repo.set_ref("refs/tags/v1", tag);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/from-tag"), old));
expect_allow(&gate(), &scenario.context()).unwrap();
}
#[test]
fn a_prose_mention_passes() {
let repo = Repo::sha1();
let base = repo.commit("base", &[]);
repo.set_ref("refs/heads/main", base);
let tip = repo.commit(
"Switch the review bot to Claude\n\n\
The Anthropic SDK is bumped, and the notes say no Co-Authored-By trailers, ever.\n\
Co-Authored-By: Anna Svensson <anna@x14.se>",
&[base],
);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::update(refname("refs/heads/main"), base, tip))
.introducing([tip]);
expect_allow(&gate(), &scenario.context()).unwrap();
}
/// The false-positive corpus of `cargo xtask trailer-guard`
/// (`xtask/src/trailer_guard.rs`, `prose_about_the_rule_is_not_a_hit`),
/// replayed verbatim against the refusing matcher, plus its positives.
#[test]
fn the_trailer_guard_corpus_agrees() {
for ok in [
"One author per commit, and no attribution trailer.",
"No Co-Authored-By trailers, ever.",
"The tooling must not add a trailer.",
"This verb reports trailers; it never rewrites history.",
"The head of a pack goes out as one copy",
] {
assert!(!is_ai_attribution(ok), "false positive on {ok:?}");
}
for hit in [
"Co-Authored-By: Claude <noreply@anthropic.com>",
"CO-AUTHORED-BY: CLAUDE",
" co-authored-by: claude ",
"Generated with Claude Code",
"\u{1f916} Generated with [Claude Code](https://claude.com/claude-code)",
] {
assert!(is_ai_attribution(hit), "missed {hit:?}");
}
}
// ─────────────────────────────── the seed ───────────────────────────────────
/// A mirror's history: trailer commits deep in it, the way the house has them.
fn historical(repo: &Repo) -> gix_hash::ObjectId {
let root = repo.commit("root", &[]);
let t1 = repo.commit(&with_trailer("2026-08-01 incident"), &[root]);
let mid = repo.commit("ordinary", &[t1]);
repo.commit(
"tip\n\n\u{1f916} Generated with [Claude Code](https://claude.com/claude-code)",
&[mid],
)
}
#[test]
fn the_seed_into_an_empty_repository_passes_and_a_user_push_into_one_is_refused() {
let repo = Repo::sha1();
let tip = historical(&repo);
let mut seed = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/master"), tip));
seed.principal = Principal::machine("installer-seed");
expect_allow(&gate(), &seed.context()).unwrap();
let user = Scenario::new(repo.objects.clone())
.pushed_by("rickard")
.pushing(RefUpdate::create(refname("refs/heads/master"), tip));
expect_reject(
&gate(),
&user.context(),
"carries an AI attribution trailer",
)
.unwrap();
}
#[test]
fn the_seed_exemption_cannot_be_borrowed() {
let repo = Repo::sha1();
let tip = historical(&repo);
let push = || {
Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/master"), tip))
};
// A USER principal named like the seed.
let impostor = push().pushed_by("installer-seed");
expect_reject(&gate(), &impostor.context(), "AI attribution trailer").unwrap();
// A machine that is not the seed.
let mut ci = push();
ci.principal = Principal::machine("ci");
expect_reject(&gate(), &ci.context(), "AI attribution trailer").unwrap();
// A repository whose policy names no seed principal.
let mut seed = push();
seed.principal = Principal::machine("installer-seed");
let no_seed = AiTrailerGate::new(AiTrailerPolicy {
seed_principal: None,
..AiTrailerPolicy::default()
});
expect_reject(&no_seed, &seed.context(), "AI attribution trailer").unwrap();
// The seed itself, once the repository has any reference: the phase is over.
let base = repo.commit("seeded", &[]);
repo.set_ref("refs/heads/main", base);
let fresh = repo.commit(&with_trailer("after the seed"), &[base]);
let mut later = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::update(refname("refs/heads/main"), base, fresh))
.introducing([fresh]);
later.principal = Principal::machine("installer-seed");
expect_reject(&gate(), &later.context(), &fresh.to_string()).unwrap();
}
// ─────────────────────────── what "new" means ───────────────────────────────
#[test]
fn a_commit_a_refused_push_left_in_the_store_is_refused_again() {
// The pack's new-object set is not the measure: the first refused push
// stored the objects, so on the second attempt the commit is not new in
// the pack. It is still new to the forge — no reference reaches it.
let repo = Repo::sha1();
let base = repo.commit("base", &[]);
repo.set_ref("refs/heads/main", base);
let bad = repo.commit(&with_trailer("second attempt"), &[base]);
let retry = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/sneaky"), bad))
.introducing([]);
expect_reject(&gate(), &retry.context(), &bad.to_string()).unwrap();
}
#[test]
fn a_skewed_clock_does_not_turn_old_history_into_a_refusal() {
// The OLD branch's tip is dated BEFORE the trailer commit under it, so a
// date-ordered walk meets the trailer commit before the OLD paint does.
// A root, so the NEW side runs out first and only the refusal-path tail
// walk can clear it.
let repo = Repo::sha1();
let old = repo.commit_at(&with_trailer("old, clock ahead"), &[], 9_000);
let skewed_tip = repo.commit_at("old tip, clock behind", &[old], 2_000);
repo.set_ref("refs/heads/history", skewed_tip);
let feature = repo.commit_at("new clean work", &[old], 10_000);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/feature"), feature))
.introducing([feature]);
expect_allow(&gate(), &scenario.context()).unwrap();
}
#[test]
fn a_new_annotated_tag_over_a_trailer_commit_is_refused() {
let repo = Repo::sha1();
let base = repo.commit("base", &[]);
repo.set_ref("refs/heads/main", base);
let bad = repo.commit(&with_trailer("tagged"), &[base]);
let tag = repo.tag("v2", bad, gix_object::Kind::Commit);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/tags/v2"), tag))
.introducing([bad, tag]);
expect_reject(&gate(), &scenario.context(), &bad.to_string()).unwrap();
}
#[test]
fn a_delete_is_not_judged() {
let repo = Repo::sha1();
let old = repo.commit(&with_trailer("history"), &[]);
repo.set_ref("refs/heads/gone", old);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::delete(refname("refs/heads/gone"), old));
expect_allow(&gate(), &scenario.context()).unwrap();
}
// ─────────────────────────── the per-repo toggle ────────────────────────────
#[test]
fn it_is_on_by_default_and_an_administrator_can_turn_it_off_per_repository() {
// No stored document at all, and a stored document written before the
// field existed: both are ON.
for config in [
PolicyConfig::default(),
PolicyConfig::from_json(r#"{"protection":[]}"#).unwrap(),
] {
assert!(config.ai_trailers.refuse);
assert!(PolicyEngine::from_config(&config)
.gate_names()
.contains(&"ai-trailers"));
}
let off = PolicyConfig::from_json(r#"{"ai_trailers":{"refuse":false}}"#).unwrap();
assert_eq!(off.ai_trailers, AiTrailerPolicy::off());
let engine = PolicyEngine::from_config(&off);
assert!(engine.is_empty(), "{engine:?}");
let repo = Repo::sha1();
let bad = repo.commit(&with_trailer("allowed here"), &[]);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/main"), bad))
.introducing([bad]);
assert!(engine.evaluate(&scenario.context()).is_allowed());
assert!(!PolicyEngine::from_config(&PolicyConfig::default())
.evaluate(&scenario.context())
.is_allowed());
// It round-trips, and a typo is an error rather than a gate quietly off.
assert_eq!(
PolicyConfig::from_json(&off.to_json().unwrap()).unwrap(),
off
);
assert!(PolicyConfig::from_json(r#"{"ai_trailers":{"refuze":false}}"#).is_err());
}
#[test]
fn it_does_not_make_the_ingest_enumerate_objects() {
assert!(!gate().reads_new_objects());
assert!(!PolicyEngine::from_config(&PolicyConfig::default()).reads_new_objects());
let with_limits = PolicyConfig {
limits: gunnar_policy::LimitPolicy::objects(10),
..PolicyConfig::default()
};
assert!(PolicyEngine::from_config(&with_limits).reads_new_objects());
}
/// GUN-PERF (2026-10-31): a push of many references into an empty repository (an
/// import: git.git is 81 000 commits under 1 005 tags) reads each new commit ONCE.
///
/// The gate used to walk every update on its own, so each tag re-read the whole
/// history below it: 328 s of server CPU for the first push of git.git, between the
/// connectivity check and the reference updates. One walk from every pushed tip
/// answers the allow; only a refusal goes back to the per-update walks to name the
/// reference.
#[test]
fn a_many_ref_push_reads_each_new_commit_once_and_not_once_per_reference() {
const COMMITS: usize = 200;
let repo = Repo::sha1();
let mut chain = Vec::new();
let mut parent: Option<gix_hash::ObjectId> = None;
for i in 0..COMMITS {
let c = repo.commit(&format!("commit {i}"), parent.as_slice());
chain.push(c);
parent = Some(c);
}
let counting = reads::Counting::new(repo.objects.clone());
let read = counting.reads();
let mut scenario = Scenario::new(counting);
for (i, c) in chain.iter().enumerate().step_by(2) {
scenario = scenario.pushing(RefUpdate::create(refname(&format!("refs/tags/t{i}")), *c));
}
let tip = *chain.last().expect("a chain");
scenario = scenario.pushing(RefUpdate::create(refname("refs/heads/main"), tip));
let updates = scenario.updates.len();
expect_allow(&gate(), &scenario.context()).unwrap();
let n = read.load(std::sync::atomic::Ordering::Relaxed);
assert!(
n <= 2 * (COMMITS + updates),
"{n} object reads for {COMMITS} new commits under {updates} references: the history is walked once per reference"
);
// The refusal is unchanged: a trailer under one reference still refuses it by name.
let bad = repo.commit(&with_trailer("late"), &[tip]);
let refused = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/tags/t0"), chain[0]))
.pushing(RefUpdate::create(refname("refs/heads/main"), bad));
let verdict = expect_reject(&gate(), &refused.context(), &bad.to_string()).unwrap();
assert_eq!(verdict.ref_name(), Some("refs/heads/main"));
}
/// A store that counts `GitServe::read` and hands everything to a `MemGitServe`.
mod reads {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use anyhow::Result;
use gunnar_store::git_storage_trait::{
Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas,
RefRow, RefUpdate, Stored, TxId,
};
use gunnar_store::mem::MemGitServe;
pub struct Counting {
inner: MemGitServe,
reads: Arc<AtomicUsize>,
}
impl Counting {
pub fn new(inner: MemGitServe) -> Self {
Counting {
inner,
reads: Arc::new(AtomicUsize::new(0)),
}
}
pub fn reads(&self) -> Arc<AtomicUsize> {
Arc::clone(&self.reads)
}
}
impl GitOps for Counting {
fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> Result<TxId> {
self.inner.put(pack, refs)
}
fn put_pack(&self, bytes: &[u8]) -> Result<TxId> {
self.inner.put_pack(bytes)
}
fn put_refs(&self, updates: &[RefUpdate]) -> Result<TxId> {
self.inner.put_refs(updates)
}
fn get(&self, oid: Oid<'_>) -> Result<Option<Stored>> {
self.inner.get(oid)
}
fn has(&self, oid: Oid<'_>) -> Result<bool> {
self.inner.has(oid)
}
fn size(&self, oid: Oid<'_>) -> Result<Option<u64>> {
self.inner.size(oid)
}
fn extents(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<Extent>>> {
self.inner.extents(oids)
}
fn refs(&self) -> Result<Vec<RefRow>> {
self.inner.refs()
}
fn update_ref(
&self,
name: &str,
old: Option<Oid<'_>>,
new: Option<Oid<'_>>,
) -> Result<TxId> {
self.inner.update_ref(name, old, new)
}
fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> Result<TxId> {
self.inner.put_refs_cas(edits)
}
fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Vec<Vec<u8>>> {
self.inner.reachable(want, have)
}
fn gc(&self) -> Result<GcReport> {
self.inner.gc()
}
}
impl GitServe for Counting {
fn read(&self, oid: Oid<'_>) -> Result<Option<(ObjType, Vec<u8>)>> {
self.reads.fetch_add(1, Ordering::Relaxed);
self.inner.read(oid)
}
fn header(&self, oid: Oid<'_>) -> Result<Option<(ObjType, u64)>> {
self.inner.header(oid)
}
fn sizes(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<u64>>> {
self.inner.sizes(oids)
}
fn head(&self) -> Result<Option<RefRow>> {
self.inner.head()
}
fn set_head(&self, target: &str) -> Result<TxId> {
self.inner.set_head(target)
}
fn emit_pack(
&self,
want: &[Oid<'_>],
have: &[Oid<'_>],
caps: &Caps,
out: &mut dyn std::io::Write,
) -> Result<PackStats> {
self.inner.emit_pack(want, have, caps, out)
}
fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Option<ReachSet>> {
self.inner.select(want, have)
}
}
}
/// GUN-PERF: receive-pack asks the gate once PER REFERENCE (`PushGate::check` takes one
/// command), so the one-walk answer above never applied to a real push. The gate of one
/// push remembers what it already cleared, and a later reference stops where an earlier
/// walk was judged clean: 101 references over 200 commits, checked one at a time, still
/// read each commit about once.
#[test]
fn references_checked_one_at_a_time_read_each_new_commit_about_once() {
const COMMITS: usize = 200;
let repo = Repo::sha1();
let mut chain = Vec::new();
let mut parent: Option<gix_hash::ObjectId> = None;
for i in 0..COMMITS {
let c = repo.commit(&format!("commit {i}"), parent.as_slice());
chain.push(c);
parent = Some(c);
}
let counting = reads::Counting::new(repo.objects.clone());
let read = counting.reads();
let shared: std::sync::Arc<dyn gunnar_store::git_storage_trait::GitServe> =
std::sync::Arc::new(counting);
let one_push = gate();
let mut updates = 0;
// `main` first, as git sends it, then the tags, oldest history first.
let mut refs = vec![("refs/heads/main".to_string(), *chain.last().unwrap())];
for (i, c) in chain.iter().enumerate().step_by(2) {
refs.push((format!("refs/tags/t{i}"), *c));
}
for (name, tip) in refs {
let scenario =
Scenario::new(Shared(shared.clone())).pushing(RefUpdate::create(refname(&name), tip));
expect_allow(&one_push, &scenario.context()).unwrap();
updates += 1;
}
let n = read.load(std::sync::atomic::Ordering::Relaxed);
assert!(
n <= 2 * (COMMITS + updates),
"{n} object reads for {COMMITS} new commits under {updates} references checked one at a time"
);
// A trailer under a later reference is still found and refused by that reference.
let bad = repo.commit(&with_trailer("late"), &[chain[10]]);
let refused = Scenario::new(Shared(shared.clone()))
.pushing(RefUpdate::create(refname("refs/heads/late"), bad));
expect_reject(&one_push, &refused.context(), &bad.to_string()).unwrap();
}
/// One store behind several scenarios, as one push hands the same store to every check.
struct Shared(std::sync::Arc<dyn gunnar_store::git_storage_trait::GitServe>);
mod shared_impl {
use super::Shared;
use anyhow::Result;
use gunnar_store::git_storage_trait::{
Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas,
RefRow, RefUpdate, Stored, TxId,
};
impl GitOps for Shared {
fn put(&self, pack: &[u8], refs: &[RefUpdate]) -> Result<TxId> {
self.0.put(pack, refs)
}
fn put_pack(&self, bytes: &[u8]) -> Result<TxId> {
self.0.put_pack(bytes)
}
fn put_refs(&self, updates: &[RefUpdate]) -> Result<TxId> {
self.0.put_refs(updates)
}
fn get(&self, oid: Oid<'_>) -> Result<Option<Stored>> {
self.0.get(oid)
}
fn has(&self, oid: Oid<'_>) -> Result<bool> {
self.0.has(oid)
}
fn size(&self, oid: Oid<'_>) -> Result<Option<u64>> {
self.0.size(oid)
}
fn extents(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<Extent>>> {
self.0.extents(oids)
}
fn refs(&self) -> Result<Vec<RefRow>> {
self.0.refs()
}
fn update_ref(
&self,
name: &str,
old: Option<Oid<'_>>,
new: Option<Oid<'_>>,
) -> Result<TxId> {
self.0.update_ref(name, old, new)
}
fn put_refs_cas(&self, edits: &[RefCas<'_>]) -> Result<TxId> {
self.0.put_refs_cas(edits)
}
fn reachable(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Vec<Vec<u8>>> {
self.0.reachable(want, have)
}
fn gc(&self) -> Result<GcReport> {
self.0.gc()
}
}
impl GitServe for Shared {
fn read(&self, oid: Oid<'_>) -> Result<Option<(ObjType, Vec<u8>)>> {
self.0.read(oid)
}
fn header(&self, oid: Oid<'_>) -> Result<Option<(ObjType, u64)>> {
self.0.header(oid)
}
fn sizes(&self, oids: &[Oid<'_>]) -> Result<Vec<Option<u64>>> {
self.0.sizes(oids)
}
fn head(&self) -> Result<Option<RefRow>> {
self.0.head()
}
fn set_head(&self, target: &str) -> Result<TxId> {
self.0.set_head(target)
}
fn emit_pack(
&self,
want: &[Oid<'_>],
have: &[Oid<'_>],
caps: &Caps,
out: &mut dyn std::io::Write,
) -> Result<PackStats> {
self.0.emit_pack(want, have, caps, out)
}
fn select(&self, want: &[Oid<'_>], have: &[Oid<'_>]) -> Result<Option<ReachSet>> {
self.0.select(want, have)
}
}
}