From df2f6fb5b9e29ce10d3edd4a8bae87df1f9b0a5c Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 17 May 2026 07:23:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(audit):=20rivet=20audit=20=E2=80=94=20AI-s?= =?UTF-8?q?ession/commit=20traceability=20gate=20(#127=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop opened by v0.10.0's ai-session schema (#127 Phase 1). New top-level read-only subcommand `rivet audit` walks the current branch's git history and enforces two gates: **Gate 1 — AI-authored commit needs a session.** For every commit detected as AI-authored (`Co-Authored-By:` containing `noreply@anthropic.com`, OR `Generated-With:`/`Created-By:` trailer matching `^(ai|ai-assisted)`), require an `ai-session` artifact in the project with `fields.commit-sha` matching the commit SHA (prefix match either direction, ≥7 chars). **Gate 2 — session must point at a real reachable commit.** For every `ai-session` artifact with `commit-sha` set, verify the commit exists (`git cat-file -e`) AND is reachable from `--until` (`git merge-base --is-ancestor`). Catches drift after rebase / force- push as well as fabricated sessions pointing at vanished commits. CLI: `rivet audit [--since ] [--until ] [--format text|json] [--strict]` - `--since` defaults to `git merge-base origin/main HEAD`, falling back to `HEAD~50`. - `--strict` exits non-zero on violations (CI mode). - JSON envelope per spec: `command`, `passed`, `since`, `until`, `ai_commits_scanned`, `ai_sessions_in_project`, `violations.{ai_commits_without_session,sessions_with_missing_commit}`, `summary.total_violations`. Read-only. Shells out to `git` (no new deps). Composes with `rivet check ai-defects-open` (PR #295) — together they cover the two operational TD1 loops the dossier §3 layer 5 names. Tests (4 integration tests, all green): - audit_passes_when_ai_commits_have_matching_sessions - audit_fails_when_ai_commit_has_no_session - audit_fails_when_session_points_at_missing_commit - audit_json_envelope_shape_on_failure Docs: new `audit` topic in `rivet-cli/src/docs.rs` (~105 lines). OUT OF SCOPE (deferred): - Auto-stamping sessions from `~/.claude/projects/*.jsonl` (Phase 2.5). - session-hash verification (Phase 2.5). - pre-commit / commit-msg hook installation (Phase 3). - DPIA-link enforcement on `invoker`-bearing sessions. Implements: REQ-002, REQ-007 Refs: FEAT-001, #127 Co-Authored-By: Claude Opus 4.7 --- rivet-cli/src/docs.rs | 119 ++++++++++ rivet-cli/src/main.rs | 377 ++++++++++++++++++++++++++++++++ rivet-cli/tests/cli_commands.rs | 303 +++++++++++++++++++++++++ 3 files changed, 799 insertions(+) diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index f325af3..0206a31 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -103,6 +103,12 @@ const TOPICS: &[DocTopic] = &[ category: "Reference", content: COMMIT_TRACEABILITY_DOC, }, + DocTopic { + slug: "audit", + title: "rivet audit — AI-session/commit traceability gate", + category: "Reference", + content: AUDIT_DOC, + }, DocTopic { slug: "cross-repo", title: "Cross-Repository Linking", @@ -976,6 +982,119 @@ the "unimplemented" report — useful when retrofitting traceability onto an existing project where historical commits lack trailers. "#; +const AUDIT_DOC: &str = r#"# rivet audit — AI-session/commit traceability gate + +`rivet audit` is a read-only CI gate that closes the loop between git +commits authored by AI assistants and the `ai-session` artifacts that +document them. It is the operational TD1 (Tool error Detection per +ISO 26262-8 §11.4.5.4) primitive for AI-authored changes; see the v0.10 +tool-qualification dossier, §3 layer 5. + +## What it checks + +`rivet audit` enforces two gates over the current branch's history. + +### Gate 1 — Every AI-authored commit must have an `ai-session` + +A commit is **AI-authored** when any of these trailer signals is present +in the commit message: + +- `Co-Authored-By:` containing `noreply@anthropic.com` (the Claude Code + convention). +- `Generated-With:` or `Created-By:` whose value starts with `ai` or + `ai-assisted` (case-insensitive). + +For every AI-authored commit, `rivet audit` looks for an `ai-session` +artifact whose `commit-sha` field matches the commit hash. Either a +short-SHA (≥7 chars) or full-SHA prefix match is accepted. + +A violation looks like: +`audit.ai-commit-without-session` — emitted in the JSON envelope and +the text report. + +### Gate 2 — Every `ai-session.commit-sha` must point at a real commit + +For every `ai-session` artifact that has `commit-sha` set, `rivet audit` +verifies via `git cat-file -e` that the commit exists, and via +`git merge-base --is-ancestor` that it is reachable from HEAD (or from +`--until` if supplied). + +A session pointing at a missing commit means either drift (a rebase or +force-push removed the commit), or a fabricated session record. Either +way it is a fail. + +Violation rule: `audit.session-commit-missing`, with `reason` either +`not-found` or `unreachable`. + +## CLI shape + +``` +rivet audit [--since ] [--until ] [--format text|json] [--strict] +``` + +- `--since` — starting git ref (default: `git merge-base origin/main HEAD`, + fallback `HEAD~50`). +- `--until` — ending git ref (default: `HEAD`). +- `--format` — `text` (default) or `json`. +- `--strict` — exit non-zero when any violation is found. Without + `--strict`, the audit still prints the report but exits 0, so local + developers can see what's wrong without their working tree breaking. + +## When to run it + +- **CI (required check):** run `rivet audit --strict --format json` on + every PR. +- **Locally:** run `rivet audit` (without `--strict`) before pushing if + the branch has AI-authored commits — the text report tells you which + commit needs an `ai-session` added. +- **Pre-release:** run `rivet audit --strict` on the release branch as + part of the qualification-evidence snapshot. + +## How it composes with the rest of the AI-provenance layer + +- `rivet check ai-defects-open` (PR #295) gates release on the + `ai-found-defect` triage state. +- `rivet audit` gates release on AI-session/commit coverage. + +Together the two cover the "who authored it" and "what defects did rivet +catch" halves of the operational TD1 evidence. Both are required CI +checks in qualified projects. + +## Example + +``` +$ rivet audit --strict +audit: FAIL — 1 violation(s) + +AI-authored commits without ai-session artifact (1): + abc1234 "feat(parser): add streaming tokens" — by Alice + +Run `rivet add --type ai-session --field commit-sha= ...` for each +orphan commit, and update or remove sessions that point at vanished +commits. +``` + +To fix, add an `ai-session` artifact: + +``` +rivet add --type ai-session \ + --id AI-SESS-042 \ + --field session-id= \ + --field model-id=claude-opus-4-7 \ + --field commit-sha=abc1234 \ + --field invoker=alice@example.com +``` + +## Out of scope + +- Auto-stamping `ai-session` from local Claude Code session logs is + Phase 2.5 (a separate PR will scan `~/.claude/projects/*.jsonl`). +- `session-hash` verification — `rivet audit` will check the field for + presence once Phase 2.5 lands; it does not currently recompute hashes. +- Git hook installation — see `rivet init --hooks` for the convenience + installer. +"#; + const CROSS_REPO_DOC: &str = r#"# Cross-Repository Artifact Linking ## Overview diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index a4ed02b..2463653 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -643,6 +643,27 @@ enum Command { strict: bool, }, + /// Audit AI-authored commits against ai-session artifacts (#127). + /// + /// Two gates: (1) every AI-authored commit on the current branch must + /// have a matching `ai-session` artifact with `commit-sha` set; (2) + /// every `ai-session` with a `commit-sha` must point at a commit that + /// exists and is reachable from HEAD. Read-only. + Audit { + /// Starting git ref (default: merge-base with origin/main, then HEAD~50). + #[arg(long)] + since: Option, + /// Ending git ref (default: HEAD). + #[arg(long)] + until: Option, + /// Output format: "text" (default) or "json" + #[arg(short, long, default_value = "text")] + format: String, + /// Exit non-zero when any violation is found (CI mode). + #[arg(long)] + strict: bool, + }, + /// Start the HTMX-powered dashboard server Serve { /// Port to listen on (0 = auto-assign) @@ -1903,6 +1924,12 @@ fn run(cli: Cli) -> Result { format, strict, } => cmd_commits(&cli, since.as_deref(), range.as_deref(), format, *strict), + Command::Audit { + since, + until, + format, + strict, + } => cmd_audit(&cli, since.as_deref(), until.as_deref(), format, *strict), Command::Serve { port, bind, watch } => { check_for_updates(); let port = *port; @@ -9520,6 +9547,356 @@ fn cmd_commits_json(analysis: &rivet_core::commits::CommitAnalysis, strict: bool Ok(!fail) } +// ── rivet audit (#127 Phase 2) ────────────────────────────────────────── + +/// A commit we observed via `git log`. +#[derive(Debug, Clone)] +struct AuditCommit { + hash: String, + subject: String, + author_name: String, + author_email: String, + body: String, +} + +impl AuditCommit { + /// True if any provenance signal in the commit metadata marks this + /// commit as authored by an AI assistant. We look for: + /// - `Co-Authored-By: ... noreply@anthropic.com` + /// - `Generated-With: ai*` / `Created-By: ai*` + fn is_ai_authored(&self) -> bool { + for line in self.body.lines() { + let trimmed = line.trim(); + // Co-Authored-By trailer with Anthropic's noreply. + if let Some(rest) = trimmed + .strip_prefix("Co-Authored-By:") + .or_else(|| trimmed.strip_prefix("Co-authored-by:")) + { + if rest.contains("noreply@anthropic.com") { + return true; + } + } + // Generated-With / Created-By trailers whose value is "ai" or + // "ai-assisted" (the project's `provenance.created-by` vocab). + // Match the bare value or that value followed by a separator — + // not arbitrary words that happen to start with "ai". + for key in [ + "Generated-With:", + "Created-By:", + "generated-with:", + "created-by:", + ] { + if let Some(rest) = trimmed.strip_prefix(key) { + let val = rest.trim().to_ascii_lowercase(); + let first_token: &str = val.split([' ', ',', ';', '\t']).next().unwrap_or(""); + if first_token == "ai" || first_token == "ai-assisted" { + return true; + } + } + } + } + false + } +} + +/// Run `git` in the project directory and return stdout on success. +fn git_in(project: &Path, args: &[&str]) -> Result { + std::process::Command::new("git") + .args(args) + .current_dir(project) + .output() + .with_context(|| format!("running git {}", args.join(" "))) +} + +/// Resolve `--since` default: try `git merge-base origin/main HEAD`, fall +/// back to `HEAD~50` if the merge-base lookup fails (no origin remote, no +/// origin/main, shallow clone, etc.). +fn default_since(project: &Path) -> String { + if let Ok(out) = git_in(project, &["merge-base", "origin/main", "HEAD"]) { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return s; + } + } + } + "HEAD~50".to_string() +} + +/// Parse `git log --format=...` output. We use a `\x00` record separator +/// at the END of each commit record to avoid being confused by newlines +/// inside the commit body. +fn parse_git_log_audit(stdout: &str) -> Vec { + let mut out = Vec::new(); + for record in stdout.split('\0') { + let record = record.trim_start_matches('\n'); + if record.trim().is_empty() { + continue; + } + // Format we emit: %H%n%an%n%ae%n%s%n%B + // i.e. hash, author name, author email, subject, body. + let mut parts = record.splitn(5, '\n'); + let hash = match parts.next() { + Some(h) => h.trim().to_string(), + None => continue, + }; + if hash.is_empty() { + continue; + } + let author_name = parts.next().unwrap_or("").to_string(); + let author_email = parts.next().unwrap_or("").to_string(); + let subject = parts.next().unwrap_or("").to_string(); + let body = parts.next().unwrap_or("").to_string(); + out.push(AuditCommit { + hash, + subject, + author_name, + author_email, + body, + }); + } + out +} + +/// Walk the AI-session artifacts, build a map from any commit-sha they +/// reference (full or truncated) → session ID. Returns the map plus the +/// list of (session_id, raw_sha) pairs (so we can also gate-check each +/// session.commit-sha for existence on Gate 2). +fn collect_sessions( + store: &Store, +) -> ( + std::collections::HashMap, + Vec<(String, String)>, + usize, +) { + let mut sha_to_session: std::collections::HashMap = + std::collections::HashMap::new(); + let mut session_shas: Vec<(String, String)> = Vec::new(); + let mut total = 0usize; + for a in store.iter().filter(|a| a.artifact_type == "ai-session") { + total += 1; + if let Some(sha) = a.fields.get("commit-sha").and_then(|v| v.as_str()) { + let sha = sha.trim().to_ascii_lowercase(); + if sha.is_empty() { + continue; + } + session_shas.push((a.id.clone(), sha.clone())); + sha_to_session.insert(sha, a.id.clone()); + } + } + (sha_to_session, session_shas, total) +} + +/// True if the session map contains an entry whose key is a prefix of +/// `commit_hash`, or `commit_hash` is a prefix of the key. Either direction +/// matches the spec's "short or long SHA prefix" rule. +fn session_for_commit<'a>( + sha_to_session: &'a std::collections::HashMap, + commit_hash: &str, +) -> Option<&'a String> { + let lower = commit_hash.to_ascii_lowercase(); + // Exact / prefix match: scan all keys. + for (k, v) in sha_to_session { + if lower.starts_with(k.as_str()) || k.starts_with(lower.as_str()) { + return Some(v); + } + } + None +} + +/// Implementation of `rivet audit`. +fn cmd_audit( + cli: &Cli, + since: Option<&str>, + until: Option<&str>, + format: &str, + strict: bool, +) -> Result { + validate_format(format, &["text", "json"])?; + + let ctx = ProjectContext::load(cli)?; + let project_path = std::fs::canonicalize(&cli.project).unwrap_or_else(|_| cli.project.clone()); + + // Resolve since/until refs. + let since_ref = match since { + Some(s) => s.to_string(), + None => default_since(&project_path), + }; + let until_ref = until.unwrap_or("HEAD").to_string(); + let range = format!("{}..{}", since_ref, until_ref); + + // Format the log. Use \x00 as a record separator at the end of each + // commit record so the body's internal newlines don't break parsing. + let log_format = "%H%n%an%n%ae%n%s%n%B"; + let log_arg = format!("--format={log_format}%x00"); + let log_out = + git_in(&project_path, &["log", &log_arg, &range]).context("running git log for audit")?; + if !log_out.status.success() { + let stderr = String::from_utf8_lossy(&log_out.stderr); + anyhow::bail!("git log failed for range '{}': {}", range, stderr.trim()); + } + let stdout = String::from_utf8_lossy(&log_out.stdout); + let commits = parse_git_log_audit(&stdout); + + let ai_commits: Vec<&AuditCommit> = commits.iter().filter(|c| c.is_ai_authored()).collect(); + + // Build the session→commit map. + let (sha_to_session, session_shas, sessions_total) = collect_sessions(&ctx.store); + + // Gate 1: AI-authored commit without an ai-session. + let mut commits_without_session: Vec<&AuditCommit> = Vec::new(); + for c in &ai_commits { + if session_for_commit(&sha_to_session, &c.hash).is_none() { + commits_without_session.push(c); + } + } + + // Gate 2: ai-session.commit-sha that doesn't exist or isn't reachable. + let mut bad_sessions: Vec<(String, String, &'static str)> = Vec::new(); + for (session_id, sha) in &session_shas { + // git cat-file -e — exists? + let exists = git_in(&project_path, &["cat-file", "-e", sha]) + .map(|o| o.status.success()) + .unwrap_or(false); + if !exists { + bad_sessions.push((session_id.clone(), sha.clone(), "not-found")); + continue; + } + // git merge-base --is-ancestor HEAD — reachable? + let reachable = git_in( + &project_path, + &["merge-base", "--is-ancestor", sha, &until_ref], + ) + .map(|o| o.status.success()) + .unwrap_or(false); + if !reachable { + bad_sessions.push((session_id.clone(), sha.clone(), "unreachable")); + } + } + + let total_violations = commits_without_session.len() + bad_sessions.len(); + let passed = total_violations == 0; + + // Resolve since/until for the report — use short SHAs when possible. + let short_since = git_in(&project_path, &["rev-parse", "--short", &since_ref]) + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| since_ref.clone()); + let short_until = git_in(&project_path, &["rev-parse", "--short", &until_ref]) + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| until_ref.clone()); + + if format == "json" { + let commits_violations: Vec = commits_without_session + .iter() + .map(|c| { + let short = if c.hash.len() >= 7 { + &c.hash[..7] + } else { + &c.hash + }; + serde_json::json!({ + "commit": short, + "subject": c.subject, + "author": format!("{} <{}>", c.author_name, c.author_email), + "rule": "audit.ai-commit-without-session", + }) + }) + .collect(); + let session_violations: Vec = bad_sessions + .iter() + .map(|(sid, sha, reason)| { + serde_json::json!({ + "session_id": sid, + "commit_sha": sha, + "reason": reason, + "rule": "audit.session-commit-missing", + }) + }) + .collect(); + let envelope = serde_json::json!({ + "command": "audit", + "passed": passed, + "since": short_since, + "until": short_until, + "ai_commits_scanned": ai_commits.len(), + "ai_sessions_in_project": sessions_total, + "violations": { + "ai_commits_without_session": commits_violations, + "sessions_with_missing_commit": session_violations, + }, + "summary": { + "total_violations": total_violations, + } + }); + println!( + "{}", + serde_json::to_string_pretty(&envelope).context("serializing audit JSON")? + ); + let fail = strict && !passed; + return Ok(!fail); + } + + // Text output. + if passed { + println!( + "audit: PASS ({} AI-authored commit(s), {} ai-session artifact(s), all matched)", + ai_commits.len(), + sessions_total + ); + } else { + println!("audit: FAIL — {} violation(s)", total_violations); + if !commits_without_session.is_empty() { + println!(); + println!( + "AI-authored commits without ai-session artifact ({}):", + commits_without_session.len() + ); + for c in &commits_without_session { + let short = if c.hash.len() >= 7 { + &c.hash[..7] + } else { + &c.hash + }; + println!( + " {short} \"{}\" — by {} <{}>", + c.subject, c.author_name, c.author_email + ); + } + } + if !bad_sessions.is_empty() { + println!(); + println!( + "ai-session artifacts with missing or unreachable commit ({}):", + bad_sessions.len() + ); + for (sid, sha, reason) in &bad_sessions { + println!(" {sid} -> {sha} ({reason})"); + } + } + println!(); + println!("Run `rivet add --type ai-session --field commit-sha= ...` for each"); + println!("orphan commit, and update or remove sessions that point at vanished"); + println!("commits."); + } + + let fail = strict && !passed; + Ok(!fail) +} + /// Compute Levenshtein edit distance between two strings. fn levenshtein(a: &str, b: &str) -> usize { let a_len = a.len(); diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index ad8f3f4..0be7bae 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -3440,3 +3440,306 @@ fn supplier_check_classifies_delegated_dd_as_boundary() { "DD-DELEGATED must be classified as external_boundary, got: {value}" ); } + +// ── rivet audit (#127 Phase 2) ───────────────────────────────────────── +// Verifies: REQ-004 + +/// Initialize a git repo with `git init`, a baseline commit, and then a +/// second commit whose message is `message` authored by `author_email`. +/// Returns (baseline_sha, target_sha) — pass `--since ` to +/// `rivet audit` so the second commit appears in the range. +fn audit_init_git_repo( + dir: &std::path::Path, + message: &str, + author_email: &str, +) -> (String, String) { + let run = |args: &[&str]| { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git command"); + assert!( + out.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr) + ); + out + }; + + run(&["init", "-q", "-b", "main"]); + run(&["config", "user.email", "baseline@example.com"]); + run(&["config", "user.name", "Baseline"]); + run(&["config", "commit.gpgsign", "false"]); + + // Baseline commit — guaranteed not AI-authored. + std::fs::write(dir.join("README.md"), "# audit test\n").expect("write README"); + run(&["add", "README.md"]); + run(&["commit", "-q", "-m", "chore: baseline"]); + let baseline_out = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(dir) + .output() + .expect("rev-parse baseline"); + let baseline = String::from_utf8_lossy(&baseline_out.stdout) + .trim() + .to_string(); + + // Target commit — message + author are caller-controlled. + run(&["config", "user.email", author_email]); + run(&["config", "user.name", "Audit Test"]); + std::fs::write(dir.join("file.txt"), "payload\n").expect("write file.txt"); + run(&["add", "file.txt"]); + run(&["commit", "-q", "-m", message]); + let target_out = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(dir) + .output() + .expect("rev-parse target"); + let target = String::from_utf8_lossy(&target_out.stdout) + .trim() + .to_string(); + + (baseline, target) +} + +/// Write a minimal rivet.yaml + artifact YAML, ready for `rivet audit`. +fn audit_write_project(dir: &std::path::Path, artifacts_yaml: &str) { + std::fs::write( + dir.join("rivet.yaml"), + "project:\n name: audit-test\n version: \"0.1.0\"\n \ + schemas: [common]\nsources:\n - path: artifacts\n \ + format: generic-yaml\n", + ) + .expect("write rivet.yaml"); + let artifacts_dir = dir.join("artifacts"); + std::fs::create_dir_all(&artifacts_dir).expect("artifacts dir"); + std::fs::write(artifacts_dir.join("sessions.yaml"), artifacts_yaml) + .expect("write sessions.yaml"); +} + +/// Gate 1 passes when every AI-authored commit has a matching `ai-session`. +#[test] +fn audit_passes_when_ai_commits_have_matching_sessions() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir = tmp.path(); + + let (baseline, target) = audit_init_git_repo( + dir, + "feat: do a thing\n\nCo-Authored-By: Claude \n", + "alice@example.com", + ); + let short = &target[..7]; + + let artifacts = format!( + "artifacts:\n - id: AI-SESS-001\n type: ai-session\n \ + title: claude session\n fields:\n \ + session-id: abc-123\n model-id: claude-opus-4-7\n \ + commit-sha: \"{short}\"\n" + ); + audit_write_project(dir, &artifacts); + + let out = Command::new(rivet_bin()) + .args([ + "--project", + dir.to_str().unwrap(), + "audit", + "--since", + &baseline, + "--strict", + ]) + .output() + .expect("rivet audit"); + + assert!( + out.status.success(), + "audit must pass; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("audit: PASS"), + "expected PASS line; got:\n{stdout}" + ); +} + +/// Gate 1 fails (with `--strict`) when an AI-authored commit has no matching +/// `ai-session`. Without `--strict`, the same project must still exit 0 but +/// still print the violation in the report. +#[test] +fn audit_fails_when_ai_commit_has_no_session() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir = tmp.path(); + + let (baseline, target) = audit_init_git_repo( + dir, + "feat: orphan ai commit\n\nCo-Authored-By: Claude \n", + "alice@example.com", + ); + + // Project with NO ai-session artifacts. + audit_write_project(dir, "artifacts: []\n"); + + // --strict: must fail. + let out = Command::new(rivet_bin()) + .args([ + "--project", + dir.to_str().unwrap(), + "audit", + "--since", + &baseline, + "--strict", + ]) + .output() + .expect("rivet audit --strict"); + assert!( + !out.status.success(), + "audit --strict must fail when AI commits lack sessions; stdout:\n{}", + String::from_utf8_lossy(&out.stdout) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("audit: FAIL"), + "FAIL banner; got:\n{stdout}" + ); + assert!( + stdout.contains(&target[..7]), + "short SHA listed; got:\n{stdout}" + ); + + // Without --strict: exit 0, but report still printed. + let lenient = Command::new(rivet_bin()) + .args([ + "--project", + dir.to_str().unwrap(), + "audit", + "--since", + &baseline, + ]) + .output() + .expect("rivet audit"); + assert!( + lenient.status.success(), + "no-strict run must exit 0 even with violations" + ); + let lenient_out = String::from_utf8_lossy(&lenient.stdout); + assert!( + lenient_out.contains("audit: FAIL"), + "lenient run still prints the report; got:\n{lenient_out}" + ); +} + +/// Gate 2 fails when an `ai-session.commit-sha` points at a missing or +/// unreachable commit. +#[test] +fn audit_fails_when_session_points_at_missing_commit() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir = tmp.path(); + + let (baseline, _target) = + audit_init_git_repo(dir, "chore: scaffold human\n", "alice@example.com"); + + // Session points at a SHA that does NOT exist in the repo. + let artifacts = "artifacts:\n - id: AI-SESS-666\n type: ai-session\n \ + title: fabricated\n fields:\n session-id: zzz\n \ + model-id: claude-opus-4-7\n commit-sha: deadbeefdeadbeef\n"; + audit_write_project(dir, artifacts); + + let out = Command::new(rivet_bin()) + .args([ + "--project", + dir.to_str().unwrap(), + "audit", + "--since", + &baseline, + "--strict", + ]) + .output() + .expect("rivet audit --strict"); + assert!( + !out.status.success(), + "audit --strict must fail when session points at missing commit" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("AI-SESS-666"), "session id; got:\n{stdout}"); + assert!( + stdout.contains("deadbeefdeadbeef"), + "missing sha listed; got:\n{stdout}" + ); + assert!( + stdout.contains("not-found"), + "reason 'not-found'; got:\n{stdout}" + ); +} + +/// JSON envelope shape on a failing project. Verifies the documented keys +/// so downstream CI consumers can rely on them. +#[test] +fn audit_json_envelope_shape_on_failure() { + let tmp = tempfile::tempdir().expect("tempdir"); + let dir = tmp.path(); + + let (baseline, target) = audit_init_git_repo( + dir, + "feat: another orphan\n\nCo-Authored-By: Claude \n", + "alice@example.com", + ); + audit_write_project(dir, "artifacts: []\n"); + + let out = Command::new(rivet_bin()) + .args([ + "--project", + dir.to_str().unwrap(), + "audit", + "--since", + &baseline, + "--format", + "json", + ]) + .output() + .expect("rivet audit --format json"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("audit --format json must emit valid JSON"); + + assert_eq!( + parsed.get("command").and_then(|v| v.as_str()), + Some("audit"), + "command field; got: {parsed}" + ); + assert_eq!( + parsed.get("passed").and_then(|v| v.as_bool()), + Some(false), + "passed=false; got: {parsed}" + ); + let viol = parsed + .get("violations") + .and_then(|v| v.get("ai_commits_without_session")) + .and_then(|v| v.as_array()) + .expect("violations array present"); + assert_eq!(viol.len(), 1, "one violation; got: {parsed}"); + let entry = &viol[0]; + assert_eq!( + entry.get("rule").and_then(|v| v.as_str()), + Some("audit.ai-commit-without-session"), + "rule key; got: {entry}" + ); + assert!( + entry + .get("commit") + .and_then(|v| v.as_str()) + .is_some_and(|c| target.starts_with(c)), + "commit short SHA prefixes full sha; got: {entry}" + ); + assert_eq!( + parsed + .get("summary") + .and_then(|s| s.get("total_violations")) + .and_then(|v| v.as_u64()), + Some(1), + "summary.total_violations; got: {parsed}" + ); +}