use super::skill_supply_chain::{expected_verdict, validate_carrier};
use crate::exit_codes;
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
const SIGNAL_SOURCE_CLASS: &str = "static_analysis_scanner";
#[derive(Debug, clap::Args, Clone)]
pub struct AdaptSkillScanArgs {
#[arg(long, value_name = "PATH")]
pub carrier: PathBuf,
#[arg(long, value_name = "PATH")]
pub sarif: PathBuf,
#[arg(long = "known-risk-rule", value_name = "RULE_ID")]
pub known_risk_rules: Vec<String>,
#[arg(long, value_name = "PATH")]
pub out: Option<PathBuf>,
}
pub fn cmd_adapt_skill_scan(args: AdaptSkillScanArgs) -> Result<i32> {
let carrier_bytes = fs::read_to_string(&args.carrier)
.with_context(|| format!("failed to read carrier {}", args.carrier.display()))?;
let mut carrier: Value = serde_json::from_str(&carrier_bytes)
.with_context(|| format!("failed to parse carrier {}", args.carrier.display()))?;
validate_carrier(&carrier)
.context("input carrier is not a valid skill supply-chain carrier")?;
let sarif_bytes = fs::read_to_string(&args.sarif)
.with_context(|| format!("failed to read SARIF {}", args.sarif.display()))?;
let sarif: Value = serde_json::from_str(&sarif_bytes)
.with_context(|| format!("failed to parse SARIF {}", args.sarif.display()))?;
let known: BTreeSet<&str> = args.known_risk_rules.iter().map(String::as_str).collect();
let scan = sarif_to_findings(&sarif, &known)?;
augment(&mut carrier, scan)?;
validate_carrier(&carrier).context("augmented carrier failed self-validation (bug)")?;
let json = serde_json::to_string_pretty(&carrier)?;
match &args.out {
Some(path) => {
fs::write(path, format!("{json}\n"))
.with_context(|| format!("failed to write {}", path.display()))?;
eprintln!("Wrote augmented carrier to {}", path.display());
}
None => println!("{json}"),
}
Ok(exit_codes::OK)
}
struct Scan {
findings: Vec<Value>,
known_risk_signals: Vec<Value>,
}
fn sarif_to_findings(sarif: &Value, known: &BTreeSet<&str>) -> Result<Scan> {
if sarif.get("version").and_then(Value::as_str) != Some("2.1.0") {
bail!("expected a SARIF 2.1.0 log (\"version\":\"2.1.0\")");
}
let runs = sarif
.get("runs")
.and_then(Value::as_array)
.ok_or_else(|| anyhow::anyhow!("SARIF log missing runs array"))?;
let mut findings = Vec::new();
let mut known_risk_signals = Vec::new();
for run in runs {
let tool = run
.get("tool")
.and_then(|t| t.get("driver"))
.and_then(|d| d.get("name"))
.and_then(Value::as_str)
.unwrap_or("unknown");
for result in run
.get("results")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let level = result
.get("level")
.and_then(Value::as_str)
.unwrap_or("warning");
if level != "error" && level != "warning" {
continue;
}
let rule_id = result.get("ruleId").and_then(Value::as_str).unwrap_or("");
let message = result
.get("message")
.and_then(|m| m.get("text"))
.and_then(Value::as_str)
.unwrap_or("");
let is_known = known.contains(rule_id);
findings.push(json!({
"source_class": SIGNAL_SOURCE_CLASS,
"tool": tool,
"rule_id": rule_id,
"level": level,
"message": message,
"known_risk": is_known,
}));
if is_known {
known_risk_signals.push(json!({
"kind": "occurrence",
"source_class": SIGNAL_SOURCE_CLASS,
"tool": tool,
"rule_id": rule_id,
"message": message,
}));
}
}
}
Ok(Scan {
findings,
known_risk_signals,
})
}
fn augment(carrier: &mut Value, scan: Scan) -> Result<()> {
let obj = carrier
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("carrier must be a JSON object"))?;
if !scan.findings.is_empty() {
let mut existing: Vec<Value> = obj
.get("scanner_findings")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
existing.extend(scan.findings);
obj.insert("scanner_findings".into(), Value::Array(existing));
}
if !scan.known_risk_signals.is_empty() {
let mut kept: Vec<Value> = obj
.get("signals")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter(|s| s.get("kind").and_then(Value::as_str) != Some("absence"))
.cloned()
.collect()
})
.unwrap_or_default();
kept.extend(scan.known_risk_signals);
obj.insert("signals".into(), Value::Array(kept));
let mut reasons: BTreeSet<String> = obj
.get("reason_codes")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
reasons.insert("known_risk_signal_reachable".to_string());
let reasons: Vec<String> = reasons.into_iter().collect();
let reason_refs: Vec<&str> = reasons.iter().map(String::as_str).collect();
let new_verdict = expected_verdict(&reason_refs);
obj.insert("reason_codes".into(), json!(reasons));
obj.insert("verdict".into(), json!(new_verdict));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn base_carrier() -> Value {
json!({
"schema": "assay.skill_supply_chain.v0",
"root": {"name": "s", "path": "skills/s"},
"verdict": "review_complete",
"reason_codes": [],
"coverage": {
"front_matter": "present", "body_text": "present", "scripts": "present",
"lockfiles": "present", "transitive_traversal": "present"
},
"signals": [],
"non_claims": ["review_complete_is_not_skill_safe"]
})
}
fn sarif(results: Value) -> Value {
json!({
"version": "2.1.0",
"runs": [{"tool": {"driver": {"name": "SkillSpector"}}, "results": results}]
})
}
fn run(carrier: &Value, sarif: &Value, known: &[&str]) -> (i32, Value) {
let dir = tempfile::tempdir().unwrap();
let cp = dir.path().join("c.json");
let sp = dir.path().join("s.sarif");
let out = dir.path().join("out.json");
fs::write(&cp, serde_json::to_string(carrier).unwrap()).unwrap();
fs::write(&sp, serde_json::to_string(sarif).unwrap()).unwrap();
let rc = cmd_adapt_skill_scan(AdaptSkillScanArgs {
carrier: cp,
sarif: sp,
known_risk_rules: known.iter().map(|s| s.to_string()).collect(),
out: Some(out.clone()),
})
.unwrap();
let augmented = serde_json::from_str(&fs::read_to_string(&out).unwrap()).unwrap();
(rc, augmented)
}
#[test]
fn non_known_warning_attaches_as_scanner_finding_without_changing_verdict() {
let s = sarif(json!([
{"ruleId": "OVERBROAD_FS", "level": "warning", "message": {"text": "writes /etc"}}
]));
let (rc, out) = run(&base_carrier(), &s, &[]);
assert_eq!(rc, 0);
let findings = out["scanner_findings"].as_array().unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0]["source_class"], "static_analysis_scanner");
assert_eq!(findings[0]["known_risk"], false);
assert_eq!(out["signals"].as_array().unwrap().len(), 0);
assert_eq!(out["verdict"], "review_complete");
}
#[test]
fn known_risk_rule_elevates_verdict_to_transitive_risk_present() {
let s = sarif(json!([
{"ruleId": "MALICIOUS_SKILL", "level": "error", "message": {"text": "reaches known-bad"}}
]));
let (rc, out) = run(&base_carrier(), &s, &["MALICIOUS_SKILL"]);
assert_eq!(rc, 0);
assert_eq!(out["verdict"], "transitive_risk_present");
let reasons: Vec<&str> = out["reason_codes"]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect();
assert!(reasons.contains(&"known_risk_signal_reachable"));
assert_eq!(out["signals"].as_array().unwrap().len(), 1);
assert_eq!(out["signals"][0]["kind"], "occurrence");
assert_eq!(out["scanner_findings"].as_array().unwrap().len(), 1);
}
#[test]
fn note_level_results_are_skipped() {
let s = sarif(json!([
{"ruleId": "STYLE", "level": "note", "message": {"text": "minor"}}
]));
let (_rc, out) = run(&base_carrier(), &s, &[]);
assert!(
out.get("scanner_findings").is_none()
|| out["scanner_findings"].as_array().unwrap().is_empty()
);
assert_eq!(out["signals"].as_array().unwrap().len(), 0);
}
#[test]
fn scan_never_downgrades_an_incomplete_carrier() {
let mut c = base_carrier();
c["verdict"] = json!("review_incomplete");
c["reason_codes"] = json!(["missing_lockfile_evidence"]);
c["coverage"]["lockfiles"] = json!("not_present");
let s = sarif(json!([
{"ruleId": "X", "level": "warning", "message": {"text": "y"}}
]));
let (_rc, out) = run(&c, &s, &[]);
assert_eq!(out["verdict"], "review_incomplete");
}
#[test]
fn known_risk_dominates_coverage_gap_under_worst_wins() {
let mut c = base_carrier();
c["verdict"] = json!("review_incomplete");
c["reason_codes"] = json!(["missing_lockfile_evidence"]);
c["coverage"]["lockfiles"] = json!("not_present");
let s = sarif(json!([
{"ruleId": "BAD", "level": "error", "message": {"text": "z"}}
]));
let (_rc, out) = run(&c, &s, &["BAD"]);
assert_eq!(out["verdict"], "transitive_risk_present");
}
#[test]
fn known_risk_drops_prior_absence_claim() {
let mut c = base_carrier();
c["signals"] = json!([
{"kind": "absence", "source_class": "boundary_observed",
"justification": "reviewed_boundary_fully_traversed"}
]);
let s = sarif(json!([
{"ruleId": "BAD", "level": "error", "message": {"text": "y"}}
]));
let (rc, out) = run(&c, &s, &["BAD"]);
assert_eq!(rc, 0);
let signals = out["signals"].as_array().unwrap();
assert_eq!(signals.len(), 1);
assert_eq!(signals[0]["kind"], "occurrence");
}
#[test]
fn non_known_finding_does_not_disturb_a_valid_absence_claim() {
let mut c = base_carrier();
c["signals"] = json!([
{"kind": "absence", "source_class": "boundary_observed",
"justification": "reviewed_boundary_fully_traversed"}
]);
let s = sarif(json!([
{"ruleId": "STYLE", "level": "warning", "message": {"text": "minor"}}
]));
let (rc, out) = run(&c, &s, &[]);
assert_eq!(rc, 0);
assert_eq!(out["signals"].as_array().unwrap().len(), 1);
assert_eq!(out["signals"][0]["kind"], "absence");
assert_eq!(out["scanner_findings"].as_array().unwrap().len(), 1);
}
#[test]
fn rejects_non_sarif_211() {
let s = json!({"version": "2.0.0", "runs": []});
let dir = tempfile::tempdir().unwrap();
let cp = dir.path().join("c.json");
let sp = dir.path().join("s.sarif");
fs::write(&cp, serde_json::to_string(&base_carrier()).unwrap()).unwrap();
fs::write(&sp, serde_json::to_string(&s).unwrap()).unwrap();
let err = cmd_adapt_skill_scan(AdaptSkillScanArgs {
carrier: cp,
sarif: sp,
known_risk_rules: vec![],
out: None,
})
.unwrap_err();
assert!(err.to_string().contains("SARIF 2.1.0"));
}
}