From cfed17126d5b200c1dc01a0cbaf437a05d716bd0 Mon Sep 17 00:00:00 2001 From: mt4110 <4221282+mt4110@users.noreply.github.com> Date: Fri, 15 May 2026 09:06:44 +0900 Subject: [PATCH 1/2] Add decision engine v1 --- Cargo.lock | 1 + config/policy.toml.example | 3 +- crates/patchgate-cli/src/main.rs | 374 ++++++- .../patchgate-cli/tests/scan_integration.rs | 59 ++ crates/patchgate-config/src/types.rs | 8 + crates/patchgate-core/Cargo.toml | 1 + crates/patchgate-core/src/lib.rs | 9 +- crates/patchgate-core/src/model.rs | 931 +++++++++++++++++- crates/patchgate-core/src/runner.rs | 16 +- docs/02_config_reference.md | 3 + docs/03_cli_reference.md | 6 + 11 files changed, 1384 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8015ff6..576a61f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -901,6 +901,7 @@ version = "0.2.2" dependencies = [ "anyhow", "base64", + "chrono", "ed25519-dalek", "globset", "patchgate-config", diff --git a/config/policy.toml.example b/config/policy.toml.example index 3d0fa37..77cca4c 100644 --- a/config/policy.toml.example +++ b/config/policy.toml.example @@ -175,7 +175,8 @@ lockfile_bonus_penalty = 0 large_lockfile_bonus_penalty = 0 [waiver] -# Each waiver entry must include check_id, reason, approver and future expires_at (RFC3339) +# Each waiver entry must include check_id, reason, approver and future expires_at (RFC3339). +# Optional waiver_id, gate_id, evidence_id and ticket fields bind exceptions to decision schema v1. entries = [] [plugins] diff --git a/crates/patchgate-cli/src/main.rs b/crates/patchgate-cli/src/main.rs index a0e31a3..4c472c6 100644 --- a/crates/patchgate-cli/src/main.rs +++ b/crates/patchgate-cli/src/main.rs @@ -34,8 +34,9 @@ use patchgate_config::{ POLICY_VERSION_CURRENT, }; use patchgate_core::{ - failure_codes, CheckId, CheckScore, Context, Finding, Report, ReportMeta, ReviewPriority, - Runner, ScopeMode, Severity, + failure_codes, CheckId, CheckScore, Context, Decision, DecisionResult, Finding, + GateDecisionResult, Report, ReportMeta, ReviewPriority, Runner, ScopeMode, Severity, + DECISION_SCHEMA_VERSION, }; #[derive(Parser, Debug)] @@ -74,6 +75,9 @@ enum Command { /// Manage external plugin templates Plugin(PluginArgs), + /// Replay decision artifacts and print the gate outcome + Decision(DecisionArgs), + /// Replay delivery dead-letter records Delivery(DeliveryArgs), } @@ -348,6 +352,29 @@ struct DeliveryArgs { cmd: DeliveryCommand, } +#[derive(Args, Debug)] +struct DecisionArgs { + #[command(subcommand)] + cmd: DecisionCommand, +} + +#[derive(Subcommand, Debug)] +enum DecisionCommand { + /// Replay a decision or scan report JSON artifact + Replay(DecisionReplayArgs), +} + +#[derive(Args, Debug, Clone)] +struct DecisionReplayArgs { + /// Decision JSON or scan report JSON containing a decision field + #[arg(long)] + input: PathBuf, + + /// Output format: text|json + #[arg(long, default_value = "text")] + format: String, +} + #[derive(Subcommand, Debug)] enum DeliveryCommand { /// Replay dead-letter records to their original endpoints @@ -1262,6 +1289,10 @@ fn main() -> Result<()> { let code = execute_plugin(&repo_root, plugin); std::process::exit(code); } + Command::Decision(decision) => { + let code = execute_decision(decision); + std::process::exit(code); + } Command::Delivery(delivery) => { let code = execute_delivery(delivery); std::process::exit(code); @@ -1281,6 +1312,18 @@ fn execute_plugin(repo_root: &Path, plugin: PluginArgs) -> i32 { } } +fn execute_decision(decision: DecisionArgs) -> i32 { + match decision.cmd { + DecisionCommand::Replay(args) => match run_decision_replay(args) { + Ok(code) => code, + Err(err) => { + eprintln!("patchgate decision replay error: {err:#}"); + 2 + } + }, + } +} + fn execute_delivery(delivery: DeliveryArgs) -> i32 { match delivery.cmd { DeliveryCommand::Replay(args) => match run_dead_letter_replay(args) { @@ -1293,6 +1336,100 @@ fn execute_delivery(delivery: DeliveryArgs) -> i32 { } } +fn run_decision_replay(args: DecisionReplayArgs) -> Result { + let raw = fs::read_to_string(&args.input) + .with_context(|| format!("failed to read decision input: {}", args.input.display()))?; + let mut value: Value = serde_json::from_str(raw.as_str()) + .with_context(|| format!("failed to parse decision input: {}", args.input.display()))?; + mask_json_string_values(&mut value); + let decision = decision_from_replay_value(value)?; + match args.format.as_str() { + "json" => { + println!("{}", serde_json::to_string_pretty(&decision)?); + } + "text" => print_decision_text(&decision), + other => bail_invalid_decision_format(other)?, + } + Ok(decision_replay_exit_code(&decision)) +} + +fn decision_from_replay_value(value: Value) -> Result { + if value + .get("schema_version") + .and_then(Value::as_str) + .is_some_and(|schema| schema == DECISION_SCHEMA_VERSION) + { + return serde_json::from_value(value).context("failed to decode decision schema v1"); + } + if let Some(decision_value) = value.get("decision").cloned() { + let decision: Decision = + serde_json::from_value(decision_value).context("failed to decode report decision")?; + if decision.schema_version == DECISION_SCHEMA_VERSION { + return Ok(decision); + } + } + let mut report: Report = serde_json::from_value(value) + .context("input is neither decision JSON nor scan report JSON")?; + report.refresh_decision(&[], report.decision.runtime.errors.clone()); + Ok(report.decision) +} + +fn print_decision_text(decision: &Decision) { + println!( + "Decision: {} (mode={}, scope={})", + decision.result.as_str().to_uppercase(), + decision.mode, + decision.scope + ); + println!( + "Score: {}/{} ({:?}) failed={}", + decision.score.value, decision.score.threshold, decision.score.band, decision.score.failed + ); + println!("Policy authority: {}", decision.policy_authority_digest); + println!("Diff: {}", decision.diff_digest); + if decision.hard_gates.is_empty() { + println!("Hard gates: none"); + } else { + println!("Hard gates:"); + for gate in &decision.hard_gates { + println!( + "- {}: {:?} evidence={}", + gate.gate_id, + gate.result, + gate.evidence_ids.join(",") + ); + } + } + if !decision.waivers.is_empty() { + println!("Waivers:"); + for waiver in &decision.waivers { + println!( + "- {} evidence={} valid={} expires_at={}", + waiver.waiver_id, waiver.evidence_id, waiver.valid, waiver.expires_at + ); + } + } + if decision.runtime.result != patchgate_core::RuntimeDecisionStatus::Ok { + println!("Runtime: {:?}", decision.runtime.result); + for err in &decision.runtime.errors { + println!("- {}:{} {}", err.category, err.code, err.message); + } + } +} + +fn bail_invalid_decision_format(format: &str) -> Result<()> { + Err(anyhow!( + "invalid value for decision.replay.format: `{format}` (expected: text|json)" + )) +} + +fn decision_replay_exit_code(decision: &Decision) -> i32 { + match decision.result { + DecisionResult::Pass | DecisionResult::Warn => 0, + DecisionResult::Fail | DecisionResult::Error => 1, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PluginTemplateLang { Python, @@ -5325,6 +5462,7 @@ fn execute_scan( &authority_failures, opts.mode.as_str(), ); + report.refresh_decision(&cfg.waiver.entries, Vec::new()); profile.skipped_by_cache = report.skipped_by_cache; if report.skipped_by_cache { profile.check_durations_ms.clear(); @@ -7907,6 +8045,12 @@ fn print_text(report: &Report) { ); println!("Mode: {} | Scope: {}", report.mode, report.scope); println!("Fingerprint: {}", report.fingerprint); + println!( + "Decision: {} | hard_gates={} | evidence={}", + report.decision.result.as_str(), + report.decision.hard_gates.len(), + report.evidence.len() + ); println!( "Policy authority: trusted={} digest={}", report.policy_authority.trusted, report.policy_authority.digest @@ -8009,6 +8153,65 @@ fn sorted_findings_for_comment(findings: &[Finding]) -> Vec<&Finding> { sorted } +fn comment_result_label(result: DecisionResult) -> &'static str { + match result { + DecisionResult::Pass => "PASS", + DecisionResult::Fail => "FAIL", + DecisionResult::Warn => "WARN", + DecisionResult::Error => "ERROR", + } +} + +fn evidence_summary_for_comment(report: &Report, evidence_id: &str) -> String { + report + .evidence + .iter() + .find(|evidence| evidence.evidence_id == evidence_id) + .map(|evidence| { + let location = evidence + .location + .as_ref() + .map(|location| format!(" `{}`", location.path_display)) + .unwrap_or_default(); + format!("{}{}", evidence.message, location) + }) + .unwrap_or_else(|| evidence_id.to_string()) +} + +fn required_actions_for_comment(report: &Report) -> Vec { + let failed_evidence_ids: Vec<&str> = report + .decision + .hard_gates + .iter() + .filter(|gate| gate.result == GateDecisionResult::Fail) + .flat_map(|gate| gate.evidence_ids.iter().map(String::as_str)) + .collect(); + let mut actions = Vec::new(); + for evidence_id in failed_evidence_ids { + if let Some(evidence) = report + .evidence + .iter() + .find(|evidence| evidence.evidence_id == evidence_id) + { + if !evidence.remediation.is_empty() && !actions.contains(&evidence.remediation) { + actions.push(evidence.remediation.clone()); + } + } + } + actions +} + +fn score_decision_sentence(report: &Report) -> String { + let score = &report.decision.score; + if report.decision.has_failed_hard_gate() && !score.failed { + "score would pass, but a hard gate failed.".to_string() + } else if score.failed { + "score is below threshold.".to_string() + } else { + "score passes.".to_string() + } +} + fn render_github_comment(report: &Report) -> String { let mut lines = Vec::new(); let sorted_findings = sorted_findings_for_comment(&report.findings); @@ -8035,12 +8238,72 @@ fn render_github_comment(report: &Report) -> String { lines.push("".to_string()); lines.push(String::new()); - lines.push("## patchgate report".to_string()); + lines.push(format!( + "## PatchGate: {}", + comment_result_label(report.decision.result) + )); lines.push(String::new()); + lines.push(format!("- Decision id: `{}`", report.decision.decision_id)); + lines.push(format!( + "- Mode: `{}` | Scope: `{}`", + report.decision.mode, report.decision.scope + )); + lines.push(format!( + "- Policy authority: trusted=`{}` digest=`{}`", + report.policy_authority.trusted, report.decision.policy_authority_digest + )); lines.push(format!( - "- Score: **{}/100** (threshold: **{}**)", - report.score, report.threshold + "- Score: **{}/100** (threshold: **{}**, band: **{:?}**) - {}", + report.decision.score.value, + report.decision.score.threshold, + report.decision.score.band, + score_decision_sentence(report) )); + if report.decision.hard_gates.is_empty() { + lines.push("- Hard gates: none".to_string()); + } else { + let failed_gates: Vec<_> = report + .decision + .hard_gates + .iter() + .filter(|gate| gate.result == GateDecisionResult::Fail) + .collect(); + if failed_gates.is_empty() { + lines.push(format!( + "- Hard gates: {} evaluated, no unwaived failures", + report.decision.hard_gates.len() + )); + } else { + lines.push("- Hard gate failures:".to_string()); + for gate in failed_gates.iter().take(5) { + let evidence = gate + .evidence_ids + .iter() + .take(2) + .map(|id| evidence_summary_for_comment(report, id)) + .collect::>() + .join("; "); + lines.push(format!(" - `{}`: {}", gate.gate_id, evidence)); + } + } + } + let required_actions = required_actions_for_comment(report); + if !required_actions.is_empty() { + lines.push("- Required actions:".to_string()); + for action in required_actions.iter().take(5) { + lines.push(format!(" - {action}")); + } + } + if report.decision.has_failed_hard_gate() { + lines.push("- Waiver: valid, scoped waiver required for hard-gate bypass.".to_string()); + } else if !report.decision.waivers.is_empty() { + lines.push(format!( + "- Waivers: {} valid waiver(s) applied", + report.decision.waivers.len() + )); + } + lines.push(String::new()); + lines.push("### Run summary".to_string()); lines.push(format!( "- Review priority: **{:?}**", report.review_priority @@ -8263,7 +8526,10 @@ mod tests { use clap::Parser as _; use patchgate_config::{Config, PolicyAuthority, PolicyAuthorityFailure}; - use patchgate_core::{CheckId, CheckScore, Finding, Report, ReportMeta, Severity}; + use patchgate_core::{ + CheckId, CheckScore, DecisionResult, Finding, Report, ReportMeta, Severity, + SupplyChainSignal, + }; use patchgate_github::PublishAuth; use serde_json::Value; @@ -8272,19 +8538,19 @@ mod tests { apply_policy_autofixes, apply_threshold_override, assess_v1_readiness, build_cache_key, build_contract_diff_report, build_delivery_idempotency_key, build_history_summary, build_history_trend, bundle_catalog_artifact_summary, changed_file_limit_fail_open_report, - ci_template_catalog, delivery_bridge_headers, delivery_bridge_metadata, - detect_head_sha_from_env, detect_pr_number_from_env, detect_sandbox_capabilities, - effective_policy_hash, exception_expired, exception_governance_artifact_summary, - gate_exit_code, is_likely_cache_corruption, load_dead_letter_jsonl, load_policy_config, - notification_payload, parse_mode, parse_policy_preset, parse_scope, - policy_authority_relative_path, pr_head_sha_from_event_payload, - pr_number_from_event_payload, pr_number_from_ref, publish_generic_ci_payload, - recover_cache_db, redacted_endpoint, registry_provenance_artifact_summary, - render_contract_diff_report_json, render_contract_diff_report_text, render_github_comment, - resolve_audit_actor, resolve_ci_provider, resolve_ci_provider_for_publish, - resolve_comment_suppression_reason, resolve_config_path, resolve_policy_path, - resolve_publish_request, resolve_scan_options, resolve_telemetry_repo, - resolve_webhook_signature, run_dead_letter_replay, run_plugin_init, + ci_template_catalog, decision_from_replay_value, delivery_bridge_headers, + delivery_bridge_metadata, detect_head_sha_from_env, detect_pr_number_from_env, + detect_sandbox_capabilities, effective_policy_hash, exception_expired, + exception_governance_artifact_summary, gate_exit_code, is_likely_cache_corruption, + load_dead_letter_jsonl, load_policy_config, notification_payload, parse_mode, + parse_policy_preset, parse_scope, policy_authority_relative_path, + pr_head_sha_from_event_payload, pr_number_from_event_payload, pr_number_from_ref, + publish_generic_ci_payload, recover_cache_db, redacted_endpoint, + registry_provenance_artifact_summary, render_contract_diff_report_json, + render_contract_diff_report_text, render_github_comment, resolve_audit_actor, + resolve_ci_provider, resolve_ci_provider_for_publish, resolve_comment_suppression_reason, + resolve_config_path, resolve_policy_path, resolve_publish_request, resolve_scan_options, + resolve_telemetry_repo, resolve_webhook_signature, run_dead_letter_replay, run_plugin_init, run_policy_diff_contract, run_policy_lint, run_policy_verify_v1, run_policy_verify_v2, sign_webhook_payload, sorted_findings_for_comment, write_text_atomic, CiProvider, Cli, DeadLetterWriteOptions, DeliveryBridgeContext, DeliveryReplayArgs, FailureCode, @@ -9253,6 +9519,76 @@ mod tests { assert!(critical_line_idx < all_idx); } + #[test] + fn github_comment_summarizes_hard_gate_before_findings() { + let mut report = Report::new( + Vec::new(), + vec![CheckScore { + check: CheckId::DependencyUpdate, + label: "Dependency update risk".to_string(), + penalty: 0, + max_penalty: 30, + triggered: false, + }], + ReportMeta { + threshold: 70, + mode: "enforce".to_string(), + scope: "pr".to_string(), + fingerprint: "fp".to_string(), + duration_ms: 1, + skipped_by_cache: false, + }, + ); + report.supply_chain_signals.push(SupplyChainSignal { + id: "SCM-002".to_string(), + title: "Lockfile topology changed with workflow modifications".to_string(), + severity: Severity::Critical, + message: "Lockfile add/remove combined with CI/workflow edits can bypass dependency controls.".to_string(), + related_files: vec!["package-lock.json".to_string()], + tags: vec!["supply-chain".to_string()], + }); + report.refresh_decision(&[], Vec::new()); + + let comment = render_github_comment(&report); + + assert!(comment.contains("## PatchGate: FAIL")); + assert!(comment.contains("- Hard gate failures:")); + assert!(comment.contains("score would pass, but a hard gate failed.")); + assert!( + comment.find("- Hard gate failures:") < comment.find("### Priority findings"), + "hard gate summary should be above the detailed finding list" + ); + } + + #[test] + fn decision_replay_reads_embedded_report_decision() { + let mut report = Report::new( + Vec::new(), + vec![CheckScore { + check: CheckId::TestGap, + label: "Test coverage gap".to_string(), + penalty: 40, + max_penalty: 40, + triggered: true, + }], + ReportMeta { + threshold: 80, + mode: "enforce".to_string(), + scope: "pr".to_string(), + fingerprint: "fp".to_string(), + duration_ms: 1, + skipped_by_cache: false, + }, + ); + report.refresh_decision(&[], Vec::new()); + let value = serde_json::to_value(&report).expect("report json"); + + let decision = decision_from_replay_value(value).expect("decision replay"); + + assert_eq!(decision.result, DecisionResult::Fail); + assert!(decision.score.failed); + } + #[test] fn cache_key_is_deterministic_for_same_inputs() { let key1 = build_cache_key("diff-a", "policy-a", "warn", "staged"); diff --git a/crates/patchgate-cli/tests/scan_integration.rs b/crates/patchgate-cli/tests/scan_integration.rs index 3887d8e..45051ca 100644 --- a/crates/patchgate-cli/tests/scan_integration.rs +++ b/crates/patchgate-cli/tests/scan_integration.rs @@ -107,6 +107,65 @@ fn scan_json_integration_flow_is_stable() -> TestResult<()> { assert!(report.get("checks").is_some()); assert!(report.get("findings").is_some()); assert!(report.get("policy_authority").is_some()); + assert_eq!( + report.get("decision").and_then(|v| v.get("schema_version")), + Some(&serde_json::Value::String( + "patchgate.decision.v1".to_string() + )) + ); + assert!(report.get("evidence").is_some()); + + Ok(()) +} + +#[test] +fn decision_replay_accepts_scan_report_json() -> TestResult<()> { + let repo = TestRepo::create()?; + repo.append_line("src/lib.rs", "pub fn replayed() -> i32 { 4 }")?; + let report_path = repo.root().join("artifacts/report.json"); + + let output = run_patchgate( + repo.root(), + &[ + "scan", + "--scope", + "worktree", + "--format", + "json", + "--mode", + "warn", + "--no-cache", + ], + )?; + assert!( + output.status.success(), + "scan should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + fs::create_dir_all(report_path.parent().ok_or("missing report parent")?)?; + fs::write(&report_path, output.stdout)?; + + let replay = run_patchgate( + repo.root(), + &[ + "decision", + "replay", + "--input", + report_path.to_str().ok_or("invalid report path utf8")?, + "--format", + "json", + ], + )?; + assert!( + replay.status.success(), + "decision replay should succeed: {}", + String::from_utf8_lossy(&replay.stderr) + ); + let decision: serde_json::Value = serde_json::from_slice(&replay.stdout)?; + assert_eq!( + decision.get("schema_version").and_then(|v| v.as_str()), + Some("patchgate.decision.v1") + ); Ok(()) } diff --git a/crates/patchgate-config/src/types.rs b/crates/patchgate-config/src/types.rs index d02baef..d2800f4 100644 --- a/crates/patchgate-config/src/types.rs +++ b/crates/patchgate-config/src/types.rs @@ -1050,7 +1050,15 @@ pub struct WaiverConfig { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WaiverEntry { + #[serde(default)] + pub waiver_id: String, pub check_id: String, + #[serde(default)] + pub gate_id: String, + #[serde(default)] + pub evidence_id: String, + #[serde(default)] + pub ticket: String, pub reason: String, pub approver: String, pub expires_at: String, diff --git a/crates/patchgate-core/Cargo.toml b/crates/patchgate-core/Cargo.toml index 3762791..f772262 100644 --- a/crates/patchgate-core/Cargo.toml +++ b/crates/patchgate-core/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] anyhow.workspace = true +chrono.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/patchgate-core/src/lib.rs b/crates/patchgate-core/src/lib.rs index 5a928e1..c5c2148 100644 --- a/crates/patchgate-core/src/lib.rs +++ b/crates/patchgate-core/src/lib.rs @@ -3,9 +3,12 @@ pub mod model; pub mod runner; pub use model::{ - CheckId, CheckScore, Finding, PluginChangedFile, PluginFinding, PluginInput, + CheckId, CheckScore, Decision, DecisionResult, Evidence, EvidenceConfidence, EvidenceImpact, + EvidenceLocation, EvidenceProducer, EvidenceProducerKind, EvidenceRule, Finding, + GateDecisionResult, HardGateResult, PluginChangedFile, PluginFinding, PluginInput, PluginInputV2Shadow, PluginInvocation, PluginInvocationStatus, PluginOutput, - PluginShadowContract, PluginShadowMetadata, Report, ReportMeta, ReviewPriority, Severity, - SupplyChainSignal, + PluginShadowContract, PluginShadowMetadata, Report, ReportMeta, ReviewPriority, + RuntimeDecisionError, RuntimeDecisionStatus, RuntimeResult, ScoreResult, Severity, + SupplyChainSignal, WaiverResult, DECISION_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, }; pub use runner::{ChangeStatus, ChangedFile, Context, DiffData, Runner, ScopeMode}; diff --git a/crates/patchgate-core/src/model.rs b/crates/patchgate-core/src/model.rs index fe631e2..4851f17 100644 --- a/crates/patchgate-core/src/model.rs +++ b/crates/patchgate-core/src/model.rs @@ -1,7 +1,12 @@ use std::collections::BTreeMap; -use patchgate_config::PolicyAuthority; +use chrono::{DateTime, Utc}; +use patchgate_config::{PolicyAuthority, WaiverEntry}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const EVIDENCE_SCHEMA_VERSION: &str = "patchgate.evidence.v1"; +pub const DECISION_SCHEMA_VERSION: &str = "patchgate.decision.v1"; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] @@ -18,8 +23,10 @@ pub enum CheckId { TestGap, DangerousChange, DependencyUpdate, + SupplyChain, ExternalPlugin, PolicyAuthority, + Runtime, } impl CheckId { @@ -28,8 +35,10 @@ impl CheckId { CheckId::TestGap => "test_gap", CheckId::DangerousChange => "dangerous_change", CheckId::DependencyUpdate => "dependency_update", + CheckId::SupplyChain => "supply_chain", CheckId::ExternalPlugin => "external_plugin", CheckId::PolicyAuthority => "policy_authority", + CheckId::Runtime => "runtime", } } @@ -38,8 +47,10 @@ impl CheckId { CheckId::TestGap => "Test coverage gap", CheckId::DangerousChange => "Dangerous file changes", CheckId::DependencyUpdate => "Dependency update risk", + CheckId::SupplyChain => "Supply-chain hard gate", CheckId::ExternalPlugin => "External plugin risk", CheckId::PolicyAuthority => "Policy authority", + CheckId::Runtime => "Runtime", } } } @@ -129,6 +140,10 @@ pub struct Report { pub plugin_invocations: Vec, #[serde(default)] pub policy_authority: PolicyAuthority, + #[serde(default)] + pub evidence: Vec, + #[serde(default)] + pub decision: Decision, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -141,6 +156,231 @@ pub struct SupplyChainSignal { pub tags: Vec, } +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceProducerKind { + #[default] + Builtin, + Plugin, + Scanner, + Policy, + Waiver, + Runtime, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceConfidence { + Low, + #[default] + Medium, + High, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EvidenceProducer { + #[serde(default)] + pub kind: EvidenceProducerKind, + pub name: String, + pub version: String, + pub digest: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EvidenceRule { + pub id: String, + pub category: String, + pub docs_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EvidenceImpact { + pub hard_gate_candidate: bool, + pub score_penalty: u8, + pub max_penalty: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EvidenceLocation { + pub path_bytes_sha256: String, + pub path_display: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_path_display: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Evidence { + pub schema_version: String, + pub evidence_id: String, + pub producer: EvidenceProducer, + pub rule: EvidenceRule, + pub severity: Severity, + #[serde(default)] + pub confidence: EvidenceConfidence, + pub impact: EvidenceImpact, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub location: Option, + pub message: String, + pub remediation: String, + #[serde(default)] + pub tags: Vec, + pub fingerprint: String, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DecisionResult { + #[default] + Pass, + Fail, + Warn, + Error, +} + +impl DecisionResult { + pub fn as_str(self) -> &'static str { + match self { + DecisionResult::Pass => "pass", + DecisionResult::Fail => "fail", + DecisionResult::Warn => "warn", + DecisionResult::Error => "error", + } + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GateDecisionResult { + #[default] + Pass, + Fail, + Waived, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HardGateResult { + pub gate_id: String, + pub result: GateDecisionResult, + #[serde(default)] + pub evidence_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScoreResult { + pub value: u8, + pub threshold: u8, + pub band: ReviewPriority, + pub failed: bool, +} + +impl Default for ScoreResult { + fn default() -> Self { + Self { + value: 100, + threshold: 70, + band: ReviewPriority::P3, + failed: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WaiverResult { + pub waiver_id: String, + pub evidence_id: String, + pub valid: bool, + pub expires_at: String, + #[serde(default)] + pub reason: String, + #[serde(default)] + pub approver: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub ticket: String, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeDecisionStatus { + #[default] + Ok, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeDecisionError { + pub code: String, + pub category: String, + pub message: String, + pub classified: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeResult { + pub result: RuntimeDecisionStatus, + #[serde(default)] + pub errors: Vec, +} + +impl Default for RuntimeResult { + fn default() -> Self { + Self { + result: RuntimeDecisionStatus::Ok, + errors: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Decision { + pub schema_version: String, + pub decision_id: String, + pub result: DecisionResult, + pub mode: String, + pub scope: String, + pub policy_authority_digest: String, + pub diff_digest: String, + #[serde(default)] + pub hard_gates: Vec, + pub score: ScoreResult, + #[serde(default)] + pub waivers: Vec, + pub runtime: RuntimeResult, +} + +impl Default for Decision { + fn default() -> Self { + Self { + schema_version: DECISION_SCHEMA_VERSION.to_string(), + decision_id: "dec_unresolved".to_string(), + result: DecisionResult::Pass, + mode: String::new(), + scope: String::new(), + policy_authority_digest: "sha256:unresolved".to_string(), + diff_digest: "sha256:unresolved".to_string(), + hard_gates: Vec::new(), + score: ScoreResult::default(), + waivers: Vec::new(), + runtime: RuntimeResult::default(), + } + } +} + +impl Decision { + pub fn has_failed_hard_gate(&self) -> bool { + self.hard_gates + .iter() + .any(|gate| gate.result == GateDecisionResult::Fail) + } + + pub fn blocks_merge(&self) -> bool { + matches!(self.result, DecisionResult::Fail | DecisionResult::Error) + || self.score.failed + || self.has_failed_hard_gate() + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PluginInvocationStatus { @@ -274,7 +514,7 @@ impl Report { let should_fail = score < meta.threshold; let review_priority = review_priority_from_score(score); - Self { + let mut report = Self { findings, checks, score, @@ -292,7 +532,11 @@ impl Report { supply_chain_signals: Vec::new(), plugin_invocations: Vec::new(), policy_authority: PolicyAuthority::default(), - } + evidence: Vec::new(), + decision: Decision::default(), + }; + report.refresh_decision(&[], Vec::new()); + report } pub fn recompute_score(&mut self) { @@ -308,9 +552,443 @@ impl Report { } } self.diagnostic_hints = hints; + self.refresh_decision(&[], self.decision.runtime.errors.clone()); + } + + pub fn refresh_decision( + &mut self, + waivers: &[WaiverEntry], + runtime_errors: Vec, + ) { + self.evidence = evidence_from_report(self); + self.decision = evaluate_decision(self, waivers, runtime_errors); + self.should_fail = self.decision.blocks_merge(); + } +} + +fn evidence_from_report(report: &Report) -> Vec { + let mut evidence = Vec::new(); + for finding in &report.findings { + let max_penalty = report + .checks + .iter() + .find(|check| check.check == finding.check) + .map(|check| check.max_penalty) + .unwrap_or(finding.penalty); + evidence.push(evidence_from_finding(finding, max_penalty)); + } + for signal in &report.supply_chain_signals { + evidence.push(evidence_from_supply_chain_signal(signal)); + } + evidence +} + +fn evidence_from_finding(finding: &Finding, max_penalty: u8) -> Evidence { + let rule_id = non_empty_or(finding.rule_id.as_str(), finding.id.as_str()); + let category = non_empty_or(finding.category.as_str(), finding.check.as_str()); + let path = finding + .location + .as_ref() + .map(|location| location.file.as_str()) + .unwrap_or(""); + let fingerprint = stable_fingerprint(&[ + "finding", + rule_id, + category, + finding.title.as_str(), + finding.message.as_str(), + path, + ]); + let producer_kind = match finding.check { + CheckId::ExternalPlugin => EvidenceProducerKind::Plugin, + CheckId::PolicyAuthority => EvidenceProducerKind::Policy, + CheckId::Runtime => EvidenceProducerKind::Runtime, + _ => EvidenceProducerKind::Builtin, + }; + Evidence { + schema_version: EVIDENCE_SCHEMA_VERSION.to_string(), + evidence_id: stable_prefixed_id("ev", fingerprint.as_str()), + producer: EvidenceProducer { + kind: producer_kind, + name: finding.check.as_str().to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + digest: stable_fingerprint(&["producer", finding.check.as_str()]), + }, + rule: EvidenceRule { + id: rule_id.to_string(), + category: category.to_string(), + docs_url: finding.docs_url.clone(), + }, + severity: finding.severity, + confidence: confidence_for_severity(finding.severity), + impact: EvidenceImpact { + hard_gate_candidate: finding.severity == Severity::Critical, + score_penalty: finding.penalty, + max_penalty, + }, + location: finding.location.as_ref().map(|location| EvidenceLocation { + path_bytes_sha256: sha256_digest_bytes(location.file.as_bytes()), + path_display: location.file.clone(), + old_path_display: None, + line: location.line, + }), + message: finding.message.clone(), + remediation: remediation_for_finding(finding), + tags: finding.tags.clone(), + fingerprint, + } +} + +fn evidence_from_supply_chain_signal(signal: &SupplyChainSignal) -> Evidence { + let path = signal + .related_files + .first() + .map(String::as_str) + .unwrap_or(""); + let fingerprint = stable_fingerprint(&[ + "supply_chain", + signal.id.as_str(), + signal.title.as_str(), + signal.message.as_str(), + path, + ]); + Evidence { + schema_version: EVIDENCE_SCHEMA_VERSION.to_string(), + evidence_id: stable_prefixed_id("ev", fingerprint.as_str()), + producer: EvidenceProducer { + kind: EvidenceProducerKind::Builtin, + name: CheckId::SupplyChain.as_str().to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + digest: stable_fingerprint(&["producer", CheckId::SupplyChain.as_str()]), + }, + rule: EvidenceRule { + id: signal.id.clone(), + category: CheckId::SupplyChain.as_str().to_string(), + docs_url: "docs/SECURITY.md".to_string(), + }, + severity: signal.severity, + confidence: EvidenceConfidence::High, + impact: EvidenceImpact { + hard_gate_candidate: signal.severity == Severity::Critical, + score_penalty: 0, + max_penalty: 0, + }, + location: signal.related_files.first().map(|file| EvidenceLocation { + path_bytes_sha256: sha256_digest_bytes(file.as_bytes()), + path_display: file.clone(), + old_path_display: None, + line: None, + }), + message: signal.message.clone(), + remediation: if signal.severity == Severity::Critical { + "Review dependency provenance or revert the coupled lockfile/workflow change." + .to_string() + } else { + "Review dependency provenance and rollback safety.".to_string() + }, + tags: signal.tags.clone(), + fingerprint, + } +} + +fn evaluate_decision( + report: &Report, + waivers: &[WaiverEntry], + runtime_errors: Vec, +) -> Decision { + let runtime = RuntimeResult { + result: if runtime_errors.is_empty() { + RuntimeDecisionStatus::Ok + } else { + RuntimeDecisionStatus::Error + }, + errors: runtime_errors, + }; + let mut waiver_results = Vec::new(); + let mut hard_gate_groups: BTreeMap> = BTreeMap::new(); + for evidence in &report.evidence { + if evidence_is_hard_gate_candidate(evidence) { + hard_gate_groups + .entry(gate_id_for_evidence(evidence).to_string()) + .or_default() + .push(evidence); + } + } + + let mut hard_gates = Vec::new(); + for (gate_id, evidence_items) in hard_gate_groups { + let mut unwaived = Vec::new(); + let mut evidence_ids = Vec::new(); + for evidence in evidence_items { + evidence_ids.push(evidence.evidence_id.clone()); + match valid_waiver_for_evidence(waivers, evidence, gate_id.as_str()) { + Some(waiver) => waiver_results.push(waiver), + None => unwaived.push(evidence.evidence_id.clone()), + } + } + hard_gates.push(HardGateResult { + gate_id, + result: if unwaived.is_empty() { + GateDecisionResult::Waived + } else { + GateDecisionResult::Fail + }, + evidence_ids, + }); + } + + let score = ScoreResult { + value: report.score, + threshold: report.threshold, + band: report.review_priority, + failed: report.score < report.threshold, + }; + let hard_gate_failed = hard_gates + .iter() + .any(|gate| gate.result == GateDecisionResult::Fail); + let runtime_failed = runtime.result == RuntimeDecisionStatus::Error + && (report.mode == "enforce" || runtime.errors.iter().any(|err| !err.classified)); + let result = if runtime_failed { + DecisionResult::Error + } else if hard_gate_failed || score.failed { + if report.mode == "warn" { + DecisionResult::Warn + } else { + DecisionResult::Fail + } + } else { + DecisionResult::Pass + }; + + let mut decision = Decision { + schema_version: DECISION_SCHEMA_VERSION.to_string(), + decision_id: String::new(), + result, + mode: report.mode.clone(), + scope: report.scope.clone(), + policy_authority_digest: report.policy_authority.digest.clone(), + diff_digest: normalize_digest(report.fingerprint.as_str()), + hard_gates, + score, + waivers: waiver_results, + runtime, + }; + decision.decision_id = decision_id(&decision); + decision +} + +fn evidence_is_hard_gate_candidate(evidence: &Evidence) -> bool { + evidence.impact.hard_gate_candidate || evidence.severity == Severity::Critical +} + +fn gate_id_for_evidence(evidence: &Evidence) -> &'static str { + if evidence.rule.category == CheckId::SupplyChain.as_str() + && evidence.severity == Severity::Critical + { + "critical-supply-chain" + } else if evidence.rule.category == CheckId::PolicyAuthority.as_str() { + "policy-authority" + } else if evidence.rule.category == CheckId::Runtime.as_str() + || evidence.producer.kind == EvidenceProducerKind::Runtime + { + "runtime-error" + } else { + "critical-evidence" + } +} + +fn valid_waiver_for_evidence( + waivers: &[WaiverEntry], + evidence: &Evidence, + gate_id: &str, +) -> Option { + waivers.iter().find_map(|entry| { + if !waiver_matches_evidence(entry, evidence, gate_id) || !waiver_entry_is_valid(entry) { + return None; + } + Some(WaiverResult { + waiver_id: waiver_id_for_entry(entry, evidence, gate_id), + evidence_id: evidence.evidence_id.clone(), + valid: true, + expires_at: entry.expires_at.clone(), + reason: entry.reason.clone(), + approver: entry.approver.clone(), + ticket: entry.ticket.clone(), + }) + }) +} + +fn waiver_matches_evidence(entry: &WaiverEntry, evidence: &Evidence, gate_id: &str) -> bool { + if !entry.evidence_id.trim().is_empty() && entry.evidence_id != evidence.evidence_id { + return false; + } + if !entry.gate_id.trim().is_empty() && entry.gate_id != gate_id { + return false; + } + let check_id = entry.check_id.trim(); + check_id == gate_id + || check_id == evidence.rule.category + || check_id == evidence.producer.name + || (check_id == "critical_supply_chain" && gate_id == "critical-supply-chain") + || (check_id == "critical-supply-chain" && gate_id == "critical-supply-chain") +} + +fn waiver_entry_is_valid(entry: &WaiverEntry) -> bool { + if entry.reason.trim().is_empty() || entry.approver.trim().is_empty() { + return false; + } + DateTime::parse_from_rfc3339(entry.expires_at.as_str()) + .map(|expires| expires.with_timezone(&Utc) > Utc::now()) + .unwrap_or(false) +} + +fn waiver_id_for_entry(entry: &WaiverEntry, evidence: &Evidence, gate_id: &str) -> String { + if !entry.waiver_id.trim().is_empty() { + return entry.waiver_id.clone(); + } + stable_prefixed_id( + "wv", + stable_fingerprint(&[ + entry.check_id.as_str(), + entry.gate_id.as_str(), + entry.evidence_id.as_str(), + gate_id, + evidence.evidence_id.as_str(), + entry.expires_at.as_str(), + entry.approver.as_str(), + ]) + .as_str(), + ) +} + +fn decision_id(decision: &Decision) -> String { + let mut material = vec![ + decision.result.as_str().to_string(), + decision.mode.clone(), + decision.scope.clone(), + decision.policy_authority_digest.clone(), + decision.diff_digest.clone(), + decision.score.value.to_string(), + decision.score.threshold.to_string(), + decision.score.failed.to_string(), + format!("{:?}", decision.runtime.result), + ]; + for gate in &decision.hard_gates { + material.push(gate.gate_id.clone()); + material.push(format!("{:?}", gate.result)); + material.extend(gate.evidence_ids.iter().cloned()); + } + for waiver in &decision.waivers { + material.push(waiver.waiver_id.clone()); + material.push(waiver.evidence_id.clone()); + material.push(waiver.valid.to_string()); + material.push(waiver.expires_at.clone()); + } + stable_prefixed_id( + "dec", + stable_fingerprint( + material + .iter() + .map(String::as_str) + .collect::>() + .as_slice(), + ) + .as_str(), + ) +} + +fn normalize_digest(raw: &str) -> String { + if raw == "sha256:unresolved" { + raw.to_string() + } else if let Some(hex) = raw.strip_prefix("sha256:") { + if is_sha256_hex(hex) { + raw.to_string() + } else { + stable_fingerprint(&["diff", raw]) + } + } else if is_sha256_hex(raw) { + format!("sha256:{raw}") + } else if raw.is_empty() { + "sha256:unresolved".to_string() + } else { + stable_fingerprint(&["diff", raw]) + } +} + +fn confidence_for_severity(severity: Severity) -> EvidenceConfidence { + match severity { + Severity::Critical | Severity::High => EvidenceConfidence::High, + Severity::Medium => EvidenceConfidence::Medium, + Severity::Low => EvidenceConfidence::Low, + } +} + +fn remediation_for_finding(finding: &Finding) -> String { + if !finding.docs_url.is_empty() { + match finding.check { + CheckId::TestGap => "Add or update tests covering the changed package.".to_string(), + CheckId::DangerousChange => { + "Get the required owner review or split the high-risk change.".to_string() + } + CheckId::DependencyUpdate => { + "Review dependency provenance, lockfile integrity and rollback safety.".to_string() + } + CheckId::ExternalPlugin => { + "Fix the plugin finding or document the approved exception.".to_string() + } + CheckId::PolicyAuthority => { + "Restore trusted policy authority before running enforce mode.".to_string() + } + CheckId::SupplyChain => { + "Review dependency provenance and supply-chain control coverage.".to_string() + } + CheckId::Runtime => "Resolve the runtime error and replay the decision.".to_string(), + } + } else { + "Review the finding and document the mitigation.".to_string() } } +fn non_empty_or<'a>(value: &'a str, fallback: &'a str) -> &'a str { + if value.is_empty() { + fallback + } else { + value + } +} + +fn stable_prefixed_id(prefix: &str, material: &str) -> String { + let normalized = + if material.strip_prefix("sha256:").is_some_and(is_sha256_hex) || is_sha256_hex(material) { + material.to_string() + } else { + stable_fingerprint(&["id", material]) + }; + format!( + "{prefix}_{}", + &normalized.trim_start_matches("sha256:")[..16] + ) +} + +fn is_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn sha256_digest_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("sha256:{:x}", hasher.finalize()) +} + +fn stable_fingerprint(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part.as_bytes()); + } + format!("sha256:{:x}", hasher.finalize()) +} + fn diagnostic_hints_from_score(score: u8, should_fail: bool) -> Vec { let mut hints = Vec::new(); if should_fail { @@ -362,6 +1040,22 @@ mod tests { } } + fn sample_report(mode: &str, checks: Vec) -> Report { + Report::new( + Vec::new(), + checks, + ReportMeta { + threshold: 80, + mode: mode.to_string(), + scope: "pr".to_string(), + fingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + duration_ms: 1, + skipped_by_cache: false, + }, + ) + } + #[test] fn review_priority_boundaries_are_stable() { assert_eq!(review_priority_from_score(0), ReviewPriority::P0); @@ -468,6 +1162,8 @@ mod tests { ); assert_eq!(report_below_threshold.score, 69); assert!(report_below_threshold.should_fail); + assert_eq!(report_below_threshold.decision.result, DecisionResult::Fail); + assert!(report_below_threshold.decision.score.failed); } #[test] @@ -508,4 +1204,233 @@ mod tests { }; assert_eq!(finding.pr_template_hint(), "- [ ] `TG-001` Missing tests"); } + + #[test] + fn evidence_location_uses_path_bytes_sha256() { + let report = Report::new( + vec![Finding { + id: "TG-001".to_string(), + rule_id: "TG-RULE".to_string(), + category: "test_gap".to_string(), + docs_url: String::new(), + check: CheckId::TestGap, + title: "Missing tests".to_string(), + message: "add tests".to_string(), + severity: Severity::Medium, + penalty: 10, + location: Some(Location { + file: "src/lib.rs".to_string(), + line: Some(7), + }), + tags: vec![], + }], + vec![sample_check(CheckId::TestGap, 10, 35)], + ReportMeta { + threshold: 70, + mode: "warn".to_string(), + scope: "staged".to_string(), + fingerprint: "fp".to_string(), + duration_ms: 1, + skipped_by_cache: false, + }, + ); + + let location = report.evidence[0] + .location + .as_ref() + .expect("evidence location"); + assert_eq!( + location.path_bytes_sha256, + sha256_digest_bytes(b"src/lib.rs") + ); + assert_ne!( + location.path_bytes_sha256, + stable_fingerprint(&["path", "src/lib.rs"]) + ); + } + + #[test] + fn normalize_digest_accepts_only_valid_sha256_values() { + let bare_hex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let prefixed_hex = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let invalid_prefixed = "sha256:not-a-real-digest"; + + assert_eq!(normalize_digest(bare_hex), format!("sha256:{bare_hex}")); + assert_eq!(normalize_digest(prefixed_hex), prefixed_hex); + assert_eq!(normalize_digest("sha256:unresolved"), "sha256:unresolved"); + assert_eq!( + normalize_digest(invalid_prefixed), + stable_fingerprint(&["diff", invalid_prefixed]) + ); + } + + #[test] + fn critical_supply_chain_hard_gate_beats_perfect_score() { + let mut report = sample_report( + "enforce", + vec![sample_check(CheckId::DependencyUpdate, 0, 30)], + ); + report.supply_chain_signals.push(SupplyChainSignal { + id: "SCM-002".to_string(), + title: "Lockfile topology changed with workflow modifications".to_string(), + severity: Severity::Critical, + message: "Lockfile add/remove combined with CI/workflow edits can bypass dependency controls.".to_string(), + related_files: vec![ + ".github/workflows/ci.yml".to_string(), + "package-lock.json".to_string(), + ], + tags: vec!["supply-chain".to_string(), "workflow".to_string()], + }); + + report.refresh_decision(&[], Vec::new()); + + assert_eq!(report.score, 100); + assert!(!report.decision.score.failed); + assert!(report.decision.has_failed_hard_gate()); + assert_eq!(report.decision.result, DecisionResult::Fail); + assert!(report.should_fail); + } + + #[test] + fn valid_waiver_allows_critical_when_score_passes() { + let mut report = sample_report( + "enforce", + vec![sample_check(CheckId::DependencyUpdate, 0, 30)], + ); + report.supply_chain_signals.push(SupplyChainSignal { + id: "SCM-002".to_string(), + title: "Lockfile topology changed with workflow modifications".to_string(), + severity: Severity::Critical, + message: "Lockfile add/remove combined with CI/workflow edits can bypass dependency controls.".to_string(), + related_files: vec!["package-lock.json".to_string()], + tags: vec!["supply-chain".to_string()], + }); + let waiver = WaiverEntry { + waiver_id: "wv_supply_chain_exception".to_string(), + check_id: "critical_supply_chain".to_string(), + gate_id: "critical-supply-chain".to_string(), + evidence_id: String::new(), + ticket: "SEC-1".to_string(), + reason: "Temporary AppSec-approved migration window".to_string(), + approver: "appsec".to_string(), + expires_at: "2999-01-01T00:00:00Z".to_string(), + }; + + report.refresh_decision(&[waiver], Vec::new()); + + assert_eq!(report.decision.result, DecisionResult::Pass); + assert_eq!(report.decision.waivers.len(), 1); + assert_eq!( + report.decision.hard_gates[0].result, + GateDecisionResult::Waived + ); + assert!(!report.should_fail); + } + + #[test] + fn expired_waiver_does_not_clear_hard_gate() { + let mut report = sample_report( + "enforce", + vec![sample_check(CheckId::DependencyUpdate, 0, 30)], + ); + report.supply_chain_signals.push(SupplyChainSignal { + id: "SCM-002".to_string(), + title: "Lockfile topology changed with workflow modifications".to_string(), + severity: Severity::Critical, + message: "Lockfile add/remove combined with CI/workflow edits can bypass dependency controls.".to_string(), + related_files: vec!["package-lock.json".to_string()], + tags: vec!["supply-chain".to_string()], + }); + let waiver = WaiverEntry { + waiver_id: "wv_expired".to_string(), + check_id: "critical_supply_chain".to_string(), + gate_id: "critical-supply-chain".to_string(), + evidence_id: String::new(), + ticket: "SEC-2".to_string(), + reason: "Expired exception".to_string(), + approver: "appsec".to_string(), + expires_at: "2000-01-01T00:00:00Z".to_string(), + }; + + report.refresh_decision(&[waiver], Vec::new()); + + assert_eq!(report.decision.result, DecisionResult::Fail); + assert!(report.decision.waivers.is_empty()); + assert_eq!( + report.decision.hard_gates[0].result, + GateDecisionResult::Fail + ); + } + + #[test] + fn runtime_error_in_enforce_is_error_decision() { + let mut report = sample_report("enforce", vec![sample_check(CheckId::TestGap, 0, 35)]); + + report.refresh_decision( + &[], + vec![RuntimeDecisionError { + code: "PG-RUN-001".to_string(), + category: "runtime".to_string(), + message: "critical evaluator failed".to_string(), + classified: true, + }], + ); + + assert_eq!(report.decision.result, DecisionResult::Error); + assert_eq!(report.decision.runtime.result, RuntimeDecisionStatus::Error); + assert!(report.should_fail); + } + + #[test] + fn decision_golden_snapshot_captures_hard_gate_layers() { + let mut report = sample_report( + "enforce", + vec![sample_check(CheckId::DependencyUpdate, 0, 30)], + ); + report.supply_chain_signals.push(SupplyChainSignal { + id: "SCM-002".to_string(), + title: "Lockfile topology changed with workflow modifications".to_string(), + severity: Severity::Critical, + message: "Lockfile add/remove combined with CI/workflow edits can bypass dependency controls.".to_string(), + related_files: vec!["package-lock.json".to_string()], + tags: vec!["supply-chain".to_string()], + }); + + report.refresh_decision(&[], Vec::new()); + let mut snapshot = serde_json::to_value(&report.decision).expect("decision json"); + snapshot["decision_id"] = serde_json::json!("dec_"); + snapshot["hard_gates"][0]["evidence_ids"][0] = serde_json::json!("ev_"); + + assert_eq!( + snapshot, + serde_json::json!({ + "schema_version": "patchgate.decision.v1", + "decision_id": "dec_", + "result": "fail", + "mode": "enforce", + "scope": "pr", + "policy_authority_digest": "sha256:unresolved", + "diff_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "hard_gates": [ + { + "gate_id": "critical-supply-chain", + "result": "fail", + "evidence_ids": ["ev_"] + } + ], + "score": { + "value": 100, + "threshold": 80, + "band": "p3", + "failed": false + }, + "waivers": [], + "runtime": { + "result": "ok", + "errors": [] + } + }) + ); + } } diff --git a/crates/patchgate-core/src/runner.rs b/crates/patchgate-core/src/runner.rs index c62fb4a..bc9a907 100644 --- a/crates/patchgate-core/src/runner.rs +++ b/crates/patchgate-core/src/runner.rs @@ -204,6 +204,12 @@ impl Runner { report.diagnostic_hints.extend(plugin_outcome.diagnostics); } report.plugin_invocations = plugin_outcome.invocations; + report.refresh_decision(&self.policy.waiver.entries, Vec::new()); + if report.decision.has_failed_hard_gate() { + report + .diagnostic_hints + .push("Hard gate failure blocks the decision independently of score.".to_string()); + } Ok(report) } @@ -2962,7 +2968,7 @@ mod tests { fingerprint: "dummy".to_string(), }; - let report = runner.evaluate(&ctx, diff, "warn").expect("evaluate"); + let report = runner.evaluate(&ctx, diff, "enforce").expect("evaluate"); assert!( report .supply_chain_signals @@ -2970,6 +2976,14 @@ mod tests { .any(|s| s.id == "SCM-002"), "lockfile add/remove with CI change should trigger SCM-002" ); + assert!( + report.decision.hard_gates.iter().any(|gate| { + gate.gate_id == "critical-supply-chain" + && gate.result == crate::model::GateDecisionResult::Fail + }), + "critical supply-chain evidence should become an unwaived hard gate" + ); + assert_eq!(report.decision.result, crate::model::DecisionResult::Fail); } #[test] diff --git a/docs/02_config_reference.md b/docs/02_config_reference.md index 0cc9f0f..5304ac3 100644 --- a/docs/02_config_reference.md +++ b/docs/02_config_reference.md @@ -34,6 +34,9 @@ - `plugin_max_penalty` - `[test_gap]`, `[dangerous_change]`, `[dependency_update]` - `[waiver]` + - `entries = [{ check_id, reason, approver, expires_at }]` + - optional v1 binding fields: `waiver_id`, `gate_id`, `evidence_id`, `ticket` + - `gate_id = "critical-supply-chain"` or `check_id = "critical_supply_chain"` can scope a waiver to the critical supply-chain hard gate. ## Plugin sections (Phase81-83) diff --git a/docs/03_cli_reference.md b/docs/03_cli_reference.md index 9479dcd..32facd1 100644 --- a/docs/03_cli_reference.md +++ b/docs/03_cli_reference.md @@ -94,6 +94,12 @@ Enforce mode rejects policy-changing CLI overrides (`--threshold`, `--max-change - `--input ` - `--format ` +### `patchgate decision replay` + +- `--input ` +- `--format ` +- `patchgate.decision.v1` artifact, or a scan report containing `decision`, is replayed without rerunning diff checks. + ### `patchgate delivery replay` - `--input ` From 5de12a1dfd7a1ca20792260ae265f97cc18f0798 Mon Sep 17 00:00:00 2001 From: mt4110 <4221282+mt4110@users.noreply.github.com> Date: Fri, 15 May 2026 09:22:43 +0900 Subject: [PATCH 2/2] Fix decision review edge cases --- crates/patchgate-cli/src/main.rs | 91 ++++++++++++++++++++++++++++-- crates/patchgate-core/src/model.rs | 30 +++++++++- 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/crates/patchgate-cli/src/main.rs b/crates/patchgate-cli/src/main.rs index 4c472c6..51b2875 100644 --- a/crates/patchgate-cli/src/main.rs +++ b/crates/patchgate-cli/src/main.rs @@ -8178,16 +8178,28 @@ fn evidence_summary_for_comment(report: &Report, evidence_id: &str) -> String { .unwrap_or_else(|| evidence_id.to_string()) } -fn required_actions_for_comment(report: &Report) -> Vec { - let failed_evidence_ids: Vec<&str> = report +fn evidence_is_waived_for_comment(report: &Report, evidence_id: &str) -> bool { + report + .decision + .waivers + .iter() + .any(|waiver| waiver.valid && waiver.evidence_id == evidence_id) +} + +fn failed_evidence_ids_for_comment(report: &Report) -> Vec<&str> { + report .decision .hard_gates .iter() .filter(|gate| gate.result == GateDecisionResult::Fail) .flat_map(|gate| gate.evidence_ids.iter().map(String::as_str)) - .collect(); + .filter(|id| !evidence_is_waived_for_comment(report, id)) + .collect() +} + +fn required_actions_for_comment(report: &Report) -> Vec { let mut actions = Vec::new(); - for evidence_id in failed_evidence_ids { + for evidence_id in failed_evidence_ids_for_comment(report) { if let Some(evidence) = report .evidence .iter() @@ -8279,6 +8291,7 @@ fn render_github_comment(report: &Report) -> String { let evidence = gate .evidence_ids .iter() + .filter(|id| !evidence_is_waived_for_comment(report, id)) .take(2) .map(|id| evidence_summary_for_comment(report, id)) .collect::>() @@ -9560,6 +9573,76 @@ mod tests { ); } + #[test] + fn github_comment_omits_waived_evidence_from_failed_gate_actions() { + let mut waived = finding("waived-critical", Severity::Critical, 10); + waived.check = CheckId::DangerousChange; + waived.category = CheckId::DangerousChange.as_str().to_string(); + waived.message = "message-waived-critical".to_string(); + + let mut active = finding("active-critical", Severity::Critical, 10); + active.check = CheckId::TestGap; + active.category = CheckId::TestGap.as_str().to_string(); + active.message = "message-active-critical".to_string(); + + let mut report = Report::new( + vec![waived, active], + vec![ + CheckScore { + check: CheckId::DangerousChange, + label: "Dangerous file changes".to_string(), + penalty: 10, + max_penalty: 70, + triggered: true, + }, + CheckScore { + check: CheckId::TestGap, + label: "Test coverage gap".to_string(), + penalty: 10, + max_penalty: 35, + triggered: true, + }, + ], + ReportMeta { + threshold: 70, + mode: "enforce".to_string(), + scope: "pr".to_string(), + fingerprint: "fp".to_string(), + duration_ms: 1, + skipped_by_cache: false, + }, + ); + let waived_evidence_id = report + .evidence + .iter() + .find(|evidence| evidence.message == "message-waived-critical") + .expect("waived evidence") + .evidence_id + .clone(); + report.refresh_decision( + &[patchgate_config::WaiverEntry { + waiver_id: "wv_dangerous_exception".to_string(), + check_id: "critical-evidence".to_string(), + gate_id: "critical-evidence".to_string(), + evidence_id: waived_evidence_id, + ticket: "SEC-3".to_string(), + reason: "Approved exception".to_string(), + approver: "appsec".to_string(), + expires_at: "2999-01-01T00:00:00Z".to_string(), + }], + Vec::new(), + ); + + let comment = render_github_comment(&report); + let decision_summary = comment.split("### Run summary").next().expect("summary"); + + assert!(decision_summary.contains("message-active-critical")); + assert!(!decision_summary.contains("message-waived-critical")); + assert!(decision_summary.contains("Add or update tests covering the changed package.")); + assert!(!decision_summary + .contains("Get the required owner review or split the high-risk change.")); + } + #[test] fn decision_replay_reads_embedded_report_decision() { let mut report = Report::new( diff --git a/crates/patchgate-core/src/model.rs b/crates/patchgate-core/src/model.rs index 4851f17..6f7a6d2 100644 --- a/crates/patchgate-core/src/model.rs +++ b/crates/patchgate-core/src/model.rs @@ -374,11 +374,15 @@ impl Decision { .any(|gate| gate.result == GateDecisionResult::Fail) } - pub fn blocks_merge(&self) -> bool { + pub fn has_failing_conditions(&self) -> bool { matches!(self.result, DecisionResult::Fail | DecisionResult::Error) || self.score.failed || self.has_failed_hard_gate() } + + pub fn blocks_merge(&self) -> bool { + matches!(self.result, DecisionResult::Fail | DecisionResult::Error) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -562,7 +566,7 @@ impl Report { ) { self.evidence = evidence_from_report(self); self.decision = evaluate_decision(self, waivers, runtime_errors); - self.should_fail = self.decision.blocks_merge(); + self.should_fail = self.decision.has_failing_conditions(); } } @@ -1292,6 +1296,28 @@ mod tests { assert!(report.should_fail); } + #[test] + fn warn_decision_conditions_do_not_block_merge() { + let mut report = + sample_report("warn", vec![sample_check(CheckId::DependencyUpdate, 0, 30)]); + report.supply_chain_signals.push(SupplyChainSignal { + id: "SCM-002".to_string(), + title: "Lockfile topology changed with workflow modifications".to_string(), + severity: Severity::Critical, + message: "Lockfile add/remove combined with CI/workflow edits can bypass dependency controls.".to_string(), + related_files: vec!["package-lock.json".to_string()], + tags: vec!["supply-chain".to_string()], + }); + + report.refresh_decision(&[], Vec::new()); + + assert_eq!(report.decision.result, DecisionResult::Warn); + assert!(report.decision.has_failed_hard_gate()); + assert!(report.decision.has_failing_conditions()); + assert!(!report.decision.blocks_merge()); + assert!(report.should_fail); + } + #[test] fn valid_waiver_allows_critical_when_score_passes() { let mut report = sample_report(