assay-cli 3.26.0

Policy-as-code gate for MCP agent tool calls, with verifiable evidence and Linux kernel enforcement.
use assay_evidence::{BundleWriter, EvidenceEvent, TRUST_CARD_NON_GOALS};
use assert_cmd::Command;
use chrono::{TimeZone, Utc};
use serde_json::json;
use std::fs;

fn make_event(type_: &str, run_id: &str, seq: u64, payload: serde_json::Value) -> EvidenceEvent {
    let mut event = EvidenceEvent::new(type_, "urn:assay:test:trustcard-cli", run_id, seq, payload);
    event.time = Utc.timestamp_opt(1_700_000_000 + seq as i64, 0).unwrap();
    event
}

fn write_bundle(path: &std::path::Path, events: Vec<EvidenceEvent>) {
    let file = fs::File::create(path).unwrap();
    let mut writer = BundleWriter::new(file);
    for event in events {
        writer.add_event(event);
    }
    writer.finish().unwrap();
}

#[test]
fn trustcard_generate_writes_json_and_md_matching_trust_basis_claims() {
    let dir = tempfile::tempdir().unwrap();
    let bundle = dir.path().join("bundle.tar.gz");
    let out_dir = dir.path().join("out");
    write_bundle(
        &bundle,
        vec![make_event(
            "assay.tool.decision",
            "run_tc",
            0,
            json!({
                "tool": "tool.commit",
                "decision": "allow",
                "delegated_from": "agent:planner"
            }),
        )],
    );

    let out = Command::cargo_bin("assay")
        .unwrap()
        .arg("trust-card")
        .arg("generate")
        .arg(&bundle)
        .arg("--out-dir")
        .arg(&out_dir)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let basis_out = Command::cargo_bin("assay")
        .unwrap()
        .arg("trust-basis")
        .arg("generate")
        .arg(&bundle)
        .output()
        .unwrap();
    assert!(basis_out.status.success());
    let basis_json: serde_json::Value = serde_json::from_slice(&basis_out.stdout).unwrap();

    let card_json: serde_json::Value =
        serde_json::from_slice(&fs::read(out_dir.join("trustcard.json")).unwrap()).unwrap();

    assert_eq!(card_json["schema_version"], json!(5));
    assert_eq!(card_json["claims"], basis_json["claims"]);

    let claims = card_json["claims"].as_array().expect("claims array");
    assert_eq!(
        claims.len(),
        10,
        "trustcard must carry exactly ten frozen claims"
    );
    let expected_ids = [
        "bundle_verified",
        "signing_evidence_present",
        "provenance_backed_claims_present",
        "delegation_context_visible",
        "authorization_context_visible",
        "containment_degradation_observed",
        "external_eval_receipt_boundary_visible",
        "external_decision_receipt_boundary_visible",
        "external_inventory_receipt_boundary_visible",
        "applied_pack_findings_present",
    ];
    let ids: Vec<_> = claims
        .iter()
        .map(|c| c["id"].as_str().expect("id"))
        .collect();
    assert_eq!(
        ids,
        expected_ids.to_vec(),
        "claim id order must match T1a / generate_trust_basis"
    );

    let obj = card_json.as_object().expect("root object");
    let mut root_keys: Vec<_> = obj.keys().map(String::as_str).collect();
    root_keys.sort_unstable();
    assert_eq!(
        root_keys,
        vec!["claims", "non_goals", "schema_version"],
        "trustcard.json must not add hashes, section_id, summary fields, etc."
    );

    assert_eq!(
        card_json["non_goals"],
        serde_json::to_value(TRUST_CARD_NON_GOALS.as_slice()).unwrap()
    );

    let md = String::from_utf8(fs::read(out_dir.join("trustcard.md")).unwrap()).unwrap();
    assert!(md.contains("## Non-goals"));
    for line in TRUST_CARD_NON_GOALS {
        assert!(md.contains(&format!("- {line}")));
    }
    assert!(md.contains("| id | level | source | boundary | note |"));

    let html = String::from_utf8(fs::read(out_dir.join("trustcard.html")).unwrap()).unwrap();
    assert!(html.starts_with("<!doctype html>\n"));
    assert_eq!(
        html.matches("data-claim-id=").count(),
        claims.len(),
        "trustcard.html must render the same claim rows as trustcard.json"
    );
    for id in expected_ids {
        assert!(
            html.contains(&format!("data-claim-id=\"{id}\"")),
            "missing html claim row for {id}"
        );
    }
    for line in TRUST_CARD_NON_GOALS {
        assert!(html.contains(&format!("<li>{line}</li>")));
    }
    assert!(
        html.contains("Canonical source of truth: trustcard.json"),
        "HTML must keep JSON canonical"
    );
    assert!(
        html.contains("Content-Security-Policy")
            && html.contains("script-src 'none'")
            && html.contains("prefers-color-scheme: dark")
            && html.contains("forced-colors: active"),
        "trustcard.html must carry static-page security and accessibility polish"
    );
    assert!(
        !html.contains("<script") && !html.contains("https://") && !html.contains("http://"),
        "trustcard.html must be a static no-network projection"
    );
}

#[test]
fn trustcard_legacy_alias_still_works_with_deprecation_warning() {
    let dir = tempfile::tempdir().unwrap();
    let bundle = dir.path().join("bundle-legacy.tar.gz");
    let out_dir = dir.path().join("out-legacy");
    write_bundle(
        &bundle,
        vec![make_event(
            "assay.tool.decision",
            "run_legacy_tc",
            0,
            json!({
                "tool": "tool.commit",
                "decision": "allow"
            }),
        )],
    );

    let out = Command::cargo_bin("assay")
        .unwrap()
        .arg("trustcard")
        .arg("generate")
        .arg(&bundle)
        .arg("--out-dir")
        .arg(&out_dir)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("`assay trustcard` is deprecated; use `assay trust-card` instead"),
        "missing legacy alias warning: {stderr}"
    );
    assert!(out_dir.join("trustcard.json").exists());
}

#[test]
fn trustcard_generate_pack_flag_matches_trust_basis_pack_classification() {
    let dir = tempfile::tempdir().unwrap();
    let bundle = dir.path().join("bundle-pack.tar.gz");
    let out_dir = dir.path().join("out-pack");
    write_bundle(
        &bundle,
        vec![make_event(
            "assay.tool.decision",
            "run_pack_tc",
            0,
            json!({
                "tool": "tool.commit",
                "decision": "allow",
                "principal": "user:alice"
            }),
        )],
    );

    let out = Command::cargo_bin("assay")
        .unwrap()
        .arg("trust-card")
        .arg("generate")
        .arg(&bundle)
        .arg("--out-dir")
        .arg(&out_dir)
        .arg("--pack")
        .arg("owasp-agentic-a3-a5-signal-followup")
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let card_json: serde_json::Value =
        serde_json::from_slice(&fs::read(out_dir.join("trustcard.json")).unwrap()).unwrap();
    let claims = card_json["claims"].as_array().unwrap();
    let pack_claim = claims
        .iter()
        .find(|c| c["id"] == "applied_pack_findings_present")
        .expect("claim");
    assert_eq!(pack_claim["level"], "verified");
}