diff --git a/README.md b/README.md index e13b9291..af6d639a 100644 --- a/README.md +++ b/README.md @@ -751,7 +751,7 @@ A Security run chooses its Harness from the command's `harness` word, then `[sec Codex security sessions and their Resumes set `agents.max_concurrent_threads_per_session=8` and ask for fresh sub-agents with `fork_turns: "none"`, so the skill's verifiers stay independent. -A Security run is skipped while another **Pass** on the repository is running on the machine, or while the repository has a **Ready issue**. A skip exits `0`, starts no session and keeps no Command log; its reason goes into the repository's **Activity log**, with the usual `activity.quiet_skips` behavior. Before starting work, thirdshift checks the chosen Harness and that **Node.js** is on `PATH`, since the embedded skill's report validators require it. +A Security run checks its gates in order: another **Pass** on the repository running on the machine, a **Ready issue**, a **Security finding** waiting for the **Day shift**, then an unchanged **Base branch** since the last completed Security audit. A draft advisory with no severity waits; closing it, publishing it or assigning severity ends that wait. On a private repository, an open finding issue with `needs-triage` waits until that label is removed or the issue is closed. The audited commit comes from the skill's own `run-metadata.json` under the audit root, with no separate state file. A skip exits `0`, starts no session, keeps no Command log and sends no Run notification; its reason goes into the repository's **Activity log** only when it changes, with the usual `activity.quiet_skips` behavior. Before starting work, thirdshift checks the chosen Harness and that **Node.js** is on `PATH`, since the embedded skill's report validators require it. The Security audit runs in a throwaway worktree detached at origin's Base branch head, leaving the Launch directory and local work untouched, including when `launch.pull` is set. Its Session prompt runs `thirdshift-security-audit` in full audit mode with the `quick` profile, auditing the whole repository except vendored and third-party code. It names a `SECURITY.md` or conventional threat-model document when present, reads compatible earlier runs and ends `incomplete` instead of asking for input. diff --git a/src/github/advisories.rs b/src/github/advisories.rs index 989e25b1..ce999b8f 100644 --- a/src/github/advisories.rs +++ b/src/github/advisories.rs @@ -20,6 +20,27 @@ pub enum SecurityRecords { } impl SecurityRecords { + /// An unreproduced, untriaged finding still needs the Day shift's call. + pub fn waiting_for_day_shift(&self) -> bool { + match self { + Self::Advisories(records) => records + .iter() + .any(|record| record["state"] == "draft" && record["severity"].is_null()), + Self::Issues(records) => records.iter().any(|record| { + record["state"] + .as_str() + .is_some_and(|state| state.eq_ignore_ascii_case("open")) + && record["labels"].as_array().is_some_and(|labels| { + labels.iter().any(|label| { + label["name"] + .as_str() + .is_some_and(|name| NEEDS_TRIAGE.is_named(name)) + }) + }) + }), + } + } + pub fn remember(&mut self, record: Value) -> &Value { let records = match self { Self::Advisories(records) | Self::Issues(records) => records, diff --git a/src/launch.rs b/src/launch.rs index 4dccafe8..45d59d26 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -194,6 +194,11 @@ impl BaseBranch { &self.name } + /// The head on origin sampled while preparing this Base branch. + pub fn origin_commit(&self) -> &str { + &self.origin_commit + } + /// The `launch.pull` fast-forward toward the sampled origin commit, /// only while the Base branch is still checked out as at preparation. /// No run depends on this, so a failure is only a warning and local diff --git a/src/pass.rs b/src/pass.rs index f499d8c6..0d94bb58 100644 --- a/src/pass.rs +++ b/src/pass.rs @@ -102,6 +102,8 @@ pub trait Outside { fn audit(&mut self, base: &str) -> (Result, Option); /// Private advisory or issue records in every state. fn security_records(&mut self) -> Result; + /// Whether origin's prepared Base branch is the last completed audit's commit. + fn base_unchanged_since_security_audit(&mut self) -> Result; /// Record one finding in the repository's private storage. fn create_security_record( &mut self, @@ -209,6 +211,13 @@ impl Outside for LaunchAndGitHub<'_> { GitHub::new().security_records(&self.repo.slug()) } + fn base_unchanged_since_security_audit(&mut self) -> Result { + Ok( + crate::security::audit::last_commit(self.repo, self.base.name())?.as_deref() + == Some(self.base.origin_commit()), + ) + } + fn create_security_record( &mut self, records: &SecurityRecords, @@ -295,6 +304,8 @@ mod in_memory { SecurityAudit(String), /// It listed private advisories in every state. AdvisoryList, + /// It compared the Base branch with the completed audit history. + AuditHistory, /// It recorded this fingerprint privately. CreateAdvisory(String), /// It recorded that it started work: on this issue, for a Pickup @@ -341,6 +352,8 @@ mod in_memory { audit_findings: Vec, audit_error: Option, advisories: Vec, + finding_issues: Option>, + unchanged_base: bool, /// The Architecture review session's final message, if it has one, /// or the cause it fails with. review: Result, String>, @@ -369,6 +382,8 @@ mod in_memory { audit_findings: Vec::new(), audit_error: None, advisories: Vec::new(), + finding_issues: None, + unchanged_base: false, review: Ok(None), session_log: None, ending: None, @@ -463,6 +478,16 @@ mod in_memory { self } + pub fn finding_issues(mut self, issues: Vec) -> Self { + self.finding_issues = Some(issues); + self + } + + pub fn unchanged_base(mut self) -> Self { + self.unchanged_base = true; + self + } + /// Have the Architecture review session end with `final_message`. pub fn reviewed(mut self, final_message: &str) -> Self { self.review = Ok(Some(final_message.to_string())); @@ -626,7 +651,15 @@ mod in_memory { fn security_records(&mut self) -> Result { self.calls.push(Call::AdvisoryList); - Ok(SecurityRecords::Advisories(self.advisories.clone())) + Ok(match &self.finding_issues { + Some(issues) => SecurityRecords::Issues(issues.clone()), + None => SecurityRecords::Advisories(self.advisories.clone()), + }) + } + + fn base_unchanged_since_security_audit(&mut self) -> Result { + self.calls.push(Call::AuditHistory); + Ok(self.unchanged_base) } fn create_security_record( diff --git a/src/security.rs b/src/security.rs index 29888483..edae2597 100644 --- a/src/security.rs +++ b/src/security.rs @@ -10,6 +10,7 @@ use crate::asks::Flags; use crate::config::UserConfig; use crate::failed_run::FailedRun; use crate::github::ListedIssue; +use crate::github::SecurityRecords; use crate::harness::Choice; use crate::issue::Repo; use crate::launch::{self, AlreadyRunning, Launch, Start}; @@ -66,6 +67,8 @@ impl RecordedFinding { pub enum Skipped { AlreadyRunning(AlreadyRunning), ReadyIssue(ListedIssue), + FindingWaiting, + UnchangedBase(String), } impl fmt::Display for Skipped { @@ -77,6 +80,11 @@ impl fmt::Display for Skipped { "Ready issue #{} \"{}\" goes first: {}", listed.issue.number, listed.title, listed.issue.url ), + Self::FindingWaiting => f.write_str("a Security finding is waiting for the Day shift"), + Self::UnchangedBase(base) => write!( + f, + "Base branch {base} hasn't changed since the last completed Security audit" + ), } } } @@ -138,10 +146,33 @@ fn run_through(outside: &mut impl Outside, repo: &Repo, base: &str) -> Outcome { Ok(None) => {} Err(error) => return Outcome::Audited(error.into()), } - Outcome::Audited(audit_and_record(outside, repo, base)) + let records = match outside.security_records() { + Ok(records) => records, + Err(error) => return Outcome::Audited(error.into()), + }; + if records.waiting_for_day_shift() { + let skipped = Skipped::FindingWaiting; + outside.skipped(Pass::Security, &skipped); + return Outcome::Skipped(skipped); + } + match outside.base_unchanged_since_security_audit() { + Ok(true) => { + let skipped = Skipped::UnchangedBase(base.to_string()); + outside.skipped(Pass::Security, &skipped); + return Outcome::Skipped(skipped); + } + Ok(false) => {} + Err(error) => return Outcome::Audited(error.into()), + } + Outcome::Audited(audit_and_record(outside, repo, base, records)) } -fn audit_and_record(outside: &mut impl Outside, repo: &Repo, base: &str) -> Ended { +fn audit_and_record( + outside: &mut impl Outside, + repo: &Repo, + base: &str, + mut known: SecurityRecords, +) -> Ended { let mut findings = Vec::new(); let mut log = None; let recorded = (|| -> Result { @@ -158,7 +189,6 @@ fn audit_and_record(outside: &mut impl Outside, repo: &Repo, base: &str) -> Ende if audited.findings.is_empty() { return Ok(recorded); } - let mut known = outside.security_records()?; for finding in audited.findings { let record = if let Some(record) = finding.recorded_in(&known) { recorded.existing += 1; @@ -187,6 +217,78 @@ mod tests { use super::*; use crate::pass::{Call, InMemory, widgets}; + #[test] + fn a_draft_without_severity_waits_before_prerequisites_or_audit() { + let mut outside = InMemory::default() + .advisories(vec![serde_json::json!({ + "state": "draft", "severity": null, + "ghsa_id": "GHSA-test", "summary": "Unchecked input size" + })]) + .harness_failing(); + let outcome = run_through(&mut outside, &widgets(), "main"); + assert!(matches!(outcome, Outcome::Skipped(_))); + assert_eq!( + outside.calls, + vec![ + Call::ReadySearch, + Call::AdvisoryList, + Call::Skipped("a Security finding is waiting for the Day shift".into()), + ] + ); + } + + #[test] + fn advisory_triage_ends_the_wait() { + for (state, severity, waits) in [ + ("draft", None, true), + ("draft", Some("low"), false), + ("draft", Some("high"), false), + ("published", None, false), + ("closed", None, false), + ("triage", None, false), + ] { + let mut outside = InMemory::default().advisories(vec![serde_json::json!({ + "state": state, "severity": severity + })]); + let outcome = run_through(&mut outside, &widgets(), "main"); + assert_eq!( + matches!(outcome, Outcome::Skipped(_)), + waits, + "{state}: {severity:?}" + ); + assert_eq!( + outside.calls.contains(&Call::SecurityAudit("main".into())), + !waits + ); + } + } + + #[test] + fn only_open_private_findings_still_needing_triage_wait() { + for (state, labels, waits) in [ + ("open", vec!["security-finding", "needs-triage"], true), + ("OPEN", vec!["security-finding", "Needs-Triage"], true), + ("closed", vec!["security-finding", "needs-triage"], false), + ("open", vec!["security-finding"], false), + ("open", vec!["security-finding", "wontfix"], false), + ] { + let mut outside = InMemory::default().finding_issues(vec![serde_json::json!({ + "state": state, + "labels": labels.iter().map(|name| serde_json::json!({"name":name})).collect::>() + })]); + let outcome = run_through(&mut outside, &widgets(), "main"); + assert_eq!( + matches!(outcome, Outcome::Skipped(_)), + waits, + "{state}: {labels:?}" + ); + assert_eq!( + outside.calls.contains(&Call::SecurityAudit("main".into())), + !waits + ); + } + } + #[test] fn checks_prerequisites_then_audits_without_pulling() { let mut outside = InMemory::default(); @@ -199,6 +301,8 @@ mod tests { outside.calls, vec![ Call::ReadySearch, + Call::AdvisoryList, + Call::AuditHistory, Call::HarnessCheck, Call::NodeCheck, Call::Started(None), @@ -210,6 +314,8 @@ mod tests { fn a_ready_issue_precedes_every_prerequisite_and_audit() { let mut outside = InMemory::default() .ready(7, false) + .advisories(vec![serde_json::json!({"state":"draft", "severity":null})]) + .unchanged_base() .node_failing() .harness_failing(); assert!(matches!( @@ -222,16 +328,74 @@ mod tests { )); } + #[test] + fn security_gates_choose_ready_then_waiting_then_unchanged_before_work() { + for ready in [false, true] { + for waiting in [false, true] { + for unchanged in [false, true] { + let mut outside = InMemory::default(); + if ready { + outside = outside.ready(7, false); + } + if waiting { + outside = outside.advisories(vec![ + serde_json::json!({"state":"draft", "severity":null}), + ]); + } + if unchanged { + outside = outside.unchanged_base(); + } + let outcome = run_through(&mut outside, &widgets(), "main"); + let expected = if ready { + "Ready issue #7" + } else if waiting { + "a Security finding is waiting for the Day shift" + } else if unchanged { + "Base branch main hasn't changed since the last completed Security audit" + } else { + assert!(matches!( + outcome, + Outcome::Audited(Ended { outcome: Ok(_), .. }) + )); + assert!(outside.calls.contains(&Call::SecurityAudit("main".into()))); + continue; + }; + let Outcome::Skipped(skipped) = outcome else { + panic!("Security run should skip"); + }; + assert!(skipped.to_string().starts_with(expected)); + assert!(!outside.calls.contains(&Call::HarnessCheck)); + assert_eq!(outside.calls.contains(&Call::AdvisoryList), !ready); + assert_eq!( + outside.calls.contains(&Call::AuditHistory), + !ready && !waiting + ); + } + } + } + } + #[test] fn each_failed_prerequisite_prevents_later_operations() { for (mut outside, expected) in [ ( InMemory::default().harness_failing(), - vec![Call::ReadySearch, Call::HarnessCheck], + vec![ + Call::ReadySearch, + Call::AdvisoryList, + Call::AuditHistory, + Call::HarnessCheck, + ], ), ( InMemory::default().node_failing(), - vec![Call::ReadySearch, Call::HarnessCheck, Call::NodeCheck], + vec![ + Call::ReadySearch, + Call::AdvisoryList, + Call::AuditHistory, + Call::HarnessCheck, + Call::NodeCheck, + ], ), ] { assert!(matches!( @@ -258,7 +422,20 @@ mod tests { panic!("audit should fail"); }; assert_eq!(failed.log, Some(std::path::PathBuf::from("audit.jsonl"))); - assert!(!outside.calls.contains(&Call::AdvisoryList)); + assert_eq!( + outside + .calls + .iter() + .filter(|call| **call == Call::AdvisoryList) + .count(), + 1 + ); + assert!( + !outside + .calls + .iter() + .any(|call| matches!(call, Call::CreateAdvisory(_))) + ); } #[test] diff --git a/src/security/audit.rs b/src/security/audit.rs index 86a9832b..4f7565a6 100644 --- a/src/security/audit.rs +++ b/src/security/audit.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; -use serde_json::Value; +use serde_json::{Value, json}; use crate::git::Git; use crate::github::{DraftAdvisory, Package}; @@ -24,6 +24,43 @@ pub struct Audited { pub findings: Vec, } +/// Read the skill's own run records, ignoring unfinished or unreadable records +/// so a failed audit can be retried. Completion time, rather than the directory's +/// random suffix, orders audits that started within the same second. +pub fn last_commit(repo: &Repo, base: &str) -> Result> { + let root = logs::root(repo).join("audits"); + let entries = match fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("could not read the Security audit history"), + }; + let mut last = None; + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let path = entry.path().join("run-metadata.json"); + let Some(record) = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + else { + continue; + }; + if record["run_status"] != "complete" || record["source_ref"]["branch"] != base { + continue; + } + let completed = fs::metadata(&path)?.modified()?; + if last.as_ref().is_none_or(|(time, _)| completed > *time) { + last = Some(( + completed, + record["source_ref"]["commit"].as_str().map(String::from), + )); + } + } + Ok(last.and_then(|(_, commit)| commit)) +} + pub fn check_node() -> Result<()> { node(&["--version"]) .context("Node.js is required for the Security audit validators; install node on PATH")?; @@ -70,7 +107,7 @@ pub fn run( Err(error) => return (Err(error), None), }; let logs = Logs::of_security_run(repo); - Sessions::within(&logs, worktree.path(), harness, |sessions| { + let (audited, log) = Sessions::within(&logs, worktree.path(), harness, |sessions| { progress::step(format!("starting the Security audit of {base}")); let message = sessions.run_to_final_message(Purpose::Security, "security-audit", &prompt)?; @@ -131,7 +168,36 @@ pub fn run( }) }).collect::>()?; Ok(Audited { findings }) - }) + }); + // The session may claim completion before a validator or its final line + // fails. Keep that attempt incomplete in the same skill record. The source + // ref comes from the acquired checkout, even if the session wrote it wrong. + let recorded = finish_record(&output, &commit, base, audited.is_ok()); + let audited = match (audited, recorded) { + (Ok(audited), Ok(())) => Ok(audited), + (Err(error), _) | (_, Err(error)) => Err(error), + }; + (audited, log) +} + +fn finish_record(output: &Path, commit: &str, base: &str, complete: bool) -> Result<()> { + let path = output.join("run-metadata.json"); + let mut metadata: Value = match fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + { + Some(Value::Object(metadata)) => Value::Object(metadata), + _ if !complete => return Ok(()), + _ => bail!("could not read the completed Security audit's run record"), + }; + metadata["source_ref"] = json!({"commit": commit, "branch": base, "dirty": false}); + metadata["run_status"] = Value::String(if complete { "complete" } else { "incomplete" }.into()); + if !complete { + metadata["incomplete_reason"] = + Value::String("thirdshift did not accept the Security audit".into()); + } + fs::write(&path, serde_json::to_vec_pretty(&metadata)?) + .context("could not finish the Security audit's run record") } fn node(args: &[&str]) -> Result<()> { diff --git a/tests/security_run.rs b/tests/security_run.rs index d2f33c08..0decaf0e 100644 --- a/tests/security_run.rs +++ b/tests/security_run.rs @@ -319,6 +319,7 @@ fn a_failed_send_keeps_the_security_runs_success_or_failure() { let scenario = Scenario::new(); scenario.agent_does(&script); let without_email = scenario.run(&["secure"]); + scenario.origin_has_commit("main", "new-work.txt", "new work", "Move Base branch"); let resend = ResendStandIn::replying(500, "upstream exploded"); let result = scenario.run_with_env(&["secure", "email", "me@example.com"], &resend_env(&resend)); @@ -468,6 +469,7 @@ fn repeat_private_audits_match_open_and_closed_findings_after_triage_without_dup github["labels"][number] = json!(["Security-Finding"]); } scenario.write_gh_state(&github); + scenario.origin_has_commit("main", "new-work.txt", "new work", "Move Base branch"); let second = scenario.run(&["secure"]); assert_eq!(second.code, Some(0), "{}", second.stderr); assert!( @@ -584,6 +586,142 @@ printf '%s\n' 'Security audit: complete' > "$FAKE_CLAUDE_FINAL_MESSAGE" ) } +fn assert_two_quiet_skips_send_no_email(scenario: &Scenario) { + let resend = support::resend::ResendStandIn::replying(200, r#"{"id":"unused"}"#); + for _ in 0..2 { + let skipped = scenario.run_with_env( + &["secure", "email", "me@example.com"], + &[ + ("THIRDSHIFT_RESEND_URL", resend.url()), + ("RESEND_API_KEY", "re_test"), + ], + ); + assert_eq!(skipped.code, Some(0), "{}", skipped.stderr); + assert_eq!(skipped.stdout, ""); + assert_eq!(skipped.stderr, ""); + } + assert!(resend.requests().is_empty()); +} + +#[test] +fn an_unchanged_base_skips_after_a_completed_audit_and_resumes_when_origin_moves() { + let scenario = Scenario::new(); + scenario.user_config_is("[activity]\nquiet_skips = true\n"); + scenario.agent_does(&audit_script("[]")); + let first = scenario.run(&["secure"]); + assert_eq!(first.code, Some(0), "{}", first.stderr); + let before = + fs::read_to_string(scenario.path("home/.thirdshift/logs/acme/widgets/activity.log")) + .unwrap(); + let commands = scenario.entries("home/.thirdshift/logs/acme/widgets/commands/secure"); + assert_two_quiet_skips_send_no_email(&scenario); + assert_eq!(scenario.claude_calls().len(), 1); + assert_eq!( + scenario.entries("home/.thirdshift/logs/acme/widgets/commands/secure"), + commands + ); + let after = + fs::read_to_string(scenario.path("home/.thirdshift/logs/acme/widgets/activity.log")) + .unwrap(); + assert_eq!(after.lines().count(), before.lines().count() + 1); + assert!( + after.contains("Base branch main hasn't changed since the last completed Security audit"), + "{after}" + ); + scenario.origin_has_commit("main", "change.txt", "new work", "Change Base branch"); + let next = scenario.run(&["secure"]); + assert_eq!(next.code, Some(0), "{}", next.stderr); + assert_eq!(scenario.claude_calls().len(), 2); +} + +#[test] +fn waiting_findings_skip_quietly_once_and_triage_allows_the_next_audit() { + for triage in [ + "closed", + "published", + "severity", + "private-closed", + "private-labelled", + ] { + let scenario = Scenario::new(); + scenario.user_config_is("[activity]\nquiet_skips = true\n"); + scenario.agent_does(&audit_script("[]")); + let mut github = scenario.gh_state(); + let private = triage.starts_with("private-"); + if private { + github["private"] = json!(true); + github["labels"]["7"] = json!(["security-finding", "Needs-Triage"]); + } else { + github["advisories"] = json!([{"state":"draft", "severity":null, "summary":"Private title", "description":"Private evidence"}]); + } + scenario.write_gh_state(&github); + assert_two_quiet_skips_send_no_email(&scenario); + assert!(scenario.claude_calls().is_empty()); + assert!( + !scenario + .path("home/.thirdshift/logs/acme/widgets/commands") + .exists() + ); + assert!( + !scenario + .path("home/.thirdshift/logs/acme/widgets/audits") + .exists() + ); + let log = + fs::read_to_string(scenario.path("home/.thirdshift/logs/acme/widgets/activity.log")) + .unwrap(); + assert_eq!(log.lines().count(), 1, "{log}"); + assert!( + log.contains("Security run skipped: a Security finding is waiting for the Day shift") + ); + assert!(!log.contains("Private")); + assert_eq!(scenario.gh_state(), github); + match triage { + "private-closed" => github["issues"]["7"] = json!("CLOSED"), + "private-labelled" => github["labels"]["7"] = json!(["security-finding"]), + "severity" => github["advisories"][0]["severity"] = json!("high"), + state => github["advisories"][0]["state"] = json!(state), + } + scenario.write_gh_state(&github); + let next = scenario.run(&["secure"]); + assert_eq!(next.code, Some(0), "{triage}: {}", next.stderr); + assert_eq!(scenario.claude_calls().len(), 1, "{triage}"); + } +} + +#[test] +fn incomplete_or_rejected_audit_artifacts_do_not_hold_the_next_attempt() { + for script in [ + audit_script("[{}]"), + audit_script("[]").replace("Security audit: complete", "Missing protocol line"), + audit_script("[]").replace("Security audit: complete", "Security audit: incomplete"), + audit_script("[]").replace( + "\"run_status\":\"complete\"", + "\"run_status\":\"incomplete\"", + ), + format!("{}\nexit 1\n", audit_script("[]")), + ] { + let scenario = Scenario::new(); + scenario.agent_does(&script); + let failed = scenario.run(&["secure"]); + assert_eq!(failed.code, Some(1), "{}", failed.stderr); + let runs = scenario.entries("home/.thirdshift/logs/acme/widgets/audits"); + let record: Value = serde_json::from_slice( + &fs::read(scenario.path(&format!( + "home/.thirdshift/logs/acme/widgets/audits/{}/run-metadata.json", + runs[0] + ))) + .unwrap(), + ) + .unwrap(); + assert_eq!(record["run_status"], "incomplete"); + scenario.agent_does(&audit_script("[]")); + let retry = scenario.run(&["secure"]); + assert_eq!(retry.code, Some(0), "{}", retry.stderr); + assert_eq!(scenario.claude_calls().len(), 2); + } +} + #[test] fn keeps_fingerprints_in_every_advisory_state_and_creates_each_new_finding_once() { for state in ["draft", "published", "closed", "triage"] { @@ -597,8 +735,12 @@ fn keeps_fingerprints_in_every_advisory_state_and_creates_each_new_finding_once( assert_eq!(github["advisories"].as_array().unwrap().len(), 2); for advisory in github["advisories"].as_array_mut().unwrap() { advisory["state"] = json!(state); + if state == "draft" { + advisory["severity"] = json!("low"); + } } scenario.write_gh_state(&github); + scenario.origin_has_commit("main", "new-work.txt", "new work", "Move Base branch"); let second = scenario.run(&["secure"]); assert_eq!(second.code, Some(0), "{}", second.stderr); assert!( @@ -1074,6 +1216,36 @@ fn security_audit_names_the_threat_model_under_docs() { ); } +#[test] +fn unchanged_base_is_remembered_separately_for_each_branch() { + let scenario = Scenario::new(); + scenario.origin_has_branch("stable", "main", &["Stable branch"]); + scenario.agent_does(&audit_script("[]")); + + let main_audit = scenario.run(&["secure", "base", "main"]); + assert_eq!(main_audit.code, Some(0), "{}", main_audit.stderr); + std::thread::sleep(std::time::Duration::from_millis(20)); + + let stable_audit = scenario.run(&["secure", "base", "stable"]); + assert_eq!(stable_audit.code, Some(0), "{}", stable_audit.stderr); + assert_eq!(scenario.claude_calls().len(), 2); + + let unchanged_main = scenario.run(&["secure", "base", "main"]); + assert_eq!(unchanged_main.code, Some(0), "{}", unchanged_main.stderr); + assert_eq!( + scenario.claude_calls().len(), + 2, + "An unchanged main must skip after stable is audited: {}", + unchanged_main.stderr + ); + let activity = + fs::read_to_string(scenario.path("home/.thirdshift/logs/acme/widgets/activity.log")) + .unwrap(); + assert!(activity.contains( + "Security run skipped: Base branch main hasn't changed since the last completed Security audit" + )); +} + #[test] fn failed_security_notification_excludes_description_from_killed_verifier() { let scenario = Scenario::new();