use anyhow::{Context, Result};
use clap::{Args, ValueEnum};
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Args, Clone)]
pub struct McpExecutionRecordArgs {
#[arg(long)]
pub attestation: PathBuf,
#[arg(long)]
pub decision: PathBuf,
#[arg(long)]
pub outcome: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = McpExecutionRecordFormat::Table)]
pub format: McpExecutionRecordFormat,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum McpExecutionRecordFormat {
Json,
Table,
}
#[derive(Debug, Serialize)]
struct PairingReport {
schema: &'static str,
ok: bool,
canonicalization: &'static str,
verification_scope: VerificationScope,
attestation: AttestationReport,
decision: DecisionReport,
outcome: Option<OutcomeReport>,
checks: Vec<CheckReport>,
claims_not_made: Vec<&'static str>,
}
#[derive(Debug, Serialize)]
struct VerificationScope {
role: &'static str,
note: &'static str,
}
#[derive(Debug, Serialize)]
struct AttestationReport {
digest: String,
nonce: Option<String>,
}
#[derive(Debug, Serialize)]
struct DecisionReport {
decision: Option<String>,
decided_at: Option<String>,
backlink: BackLinkReport,
signature_present: bool,
}
#[derive(Debug, Serialize)]
struct OutcomeReport {
status: Option<String>,
completed_at: Option<String>,
backlink: BackLinkReport,
signature_present: bool,
}
#[derive(Debug, Serialize)]
struct BackLinkReport {
attestation_digest: Option<String>,
attestation_nonce: Option<String>,
}
#[derive(Debug, Serialize)]
struct CheckReport {
id: &'static str,
ok: bool,
detail: String,
}
pub fn cmd_verify_mcp_records(args: McpExecutionRecordArgs) -> Result<i32> {
let attestation = read_json(&args.attestation)?;
let decision = read_json(&args.decision)?;
let outcome = args.outcome.as_ref().map(read_json).transpose()?;
let report = build_report(&attestation, &decision, outcome.as_ref())?;
match args.format {
McpExecutionRecordFormat::Json => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
McpExecutionRecordFormat::Table => print_table_report(&report),
}
Ok(if report.ok { 0 } else { 2 })
}
fn read_json(path: &PathBuf) -> Result<Value> {
let body =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
}
fn build_report(
attestation: &Value,
decision: &Value,
outcome: Option<&Value>,
) -> Result<PairingReport> {
let attestation_digest = jcs_digest(attestation).context("failed to digest attestation")?;
let attestation_nonce = string_at(attestation, &["issuerAsserted", "nonce"]);
let decision_backlink = backlink_report(decision)?;
let outcome_backlink = outcome.map(backlink_report).transpose()?;
let mut checks = Vec::new();
checks.push(check_eq(
"decision_attestation_digest_match",
decision_backlink.attestation_digest.as_deref(),
Some(attestation_digest.as_str()),
"decision backLink.attestationDigest matches SEP-2787 JCS digest",
));
checks.push(check_eq(
"decision_attestation_nonce_match",
decision_backlink.attestation_nonce.as_deref(),
attestation_nonce.as_deref(),
"decision backLink.attestationNonce matches issuerAsserted.nonce",
));
checks.push(check_enum(
"decision_enum",
decision_value(decision).as_deref(),
&["allow", "block", "escalate"],
));
if let Some(outcome_backlink) = &outcome_backlink {
checks.push(check_eq(
"outcome_attestation_digest_match",
outcome_backlink.attestation_digest.as_deref(),
Some(attestation_digest.as_str()),
"outcome backLink.attestationDigest matches SEP-2787 JCS digest",
));
checks.push(check_eq(
"outcome_attestation_nonce_match",
outcome_backlink.attestation_nonce.as_deref(),
attestation_nonce.as_deref(),
"outcome backLink.attestationNonce matches issuerAsserted.nonce",
));
checks.push(check_eq(
"decision_outcome_backlink_match",
outcome_backlink.attestation_digest.as_deref(),
decision_backlink.attestation_digest.as_deref(),
"decision and outcome describe the same call instance",
));
checks.push(check_enum(
"outcome_status_enum",
outcome.and_then(outcome_status).as_deref(),
&["executed", "refused", "errored"],
));
} else {
checks.push(CheckReport {
id: "outcome_absent",
ok: true,
detail: "no outcome record supplied; report is decision-only".to_string(),
});
}
let decision_report = DecisionReport {
decision: decision_value(decision),
decided_at: string_at(decision, &["decisionDerived", "decidedAt"]),
backlink: decision_backlink,
signature_present: decision.get("signature").and_then(Value::as_str).is_some(),
};
let outcome_report = match (outcome, outcome_backlink) {
(Some(outcome), Some(backlink)) => Some(OutcomeReport {
status: outcome_status(outcome),
completed_at: string_at(outcome, &["outcomeDerived", "completedAt"]),
backlink,
signature_present: outcome.get("signature").and_then(Value::as_str).is_some(),
}),
_ => None,
};
let ok = checks.iter().all(|check| check.ok);
Ok(PairingReport {
schema: "assay.mcp.execution-record-pairing.report.v0",
ok,
canonicalization: "jcs/rfc8785",
verification_scope: VerificationScope {
role: "independent-consumer",
note: "Assay verifies fixture pairing and digest commitments only; it does not emit records or act as a proxy.",
},
attestation: AttestationReport {
digest: attestation_digest,
nonce: attestation_nonce,
},
decision: decision_report,
outcome: outcome_report,
checks,
claims_not_made: vec![
"signature_verification",
"issuer_key_trust",
"policy_correctness",
"runtime_side_effect_truth",
"payload_or_result_disclosure",
],
})
}
fn jcs_digest(value: &Value) -> Result<String> {
let canonical = assay_core::mcp::jcs::to_vec(value)?;
let hash = Sha256::digest(&canonical);
Ok(format!("sha256:{}", hex::encode(hash)))
}
fn backlink_report(record: &Value) -> Result<BackLinkReport> {
let backlink = record
.get("backLink")
.or_else(|| record.get("back_link"))
.ok_or_else(|| anyhow::anyhow!("record missing backLink"))?;
Ok(BackLinkReport {
attestation_digest: string_at(backlink, &["attestationDigest"])
.or_else(|| string_at(backlink, &["attestation_digest"])),
attestation_nonce: string_at(backlink, &["attestationNonce"])
.or_else(|| string_at(backlink, &["attestation_nonce"])),
})
}
fn decision_value(record: &Value) -> Option<String> {
string_at(record, &["decisionDerived", "decision"])
.or_else(|| string_at(record, &["decision_derived", "decision"]))
}
fn outcome_status(record: &Value) -> Option<String> {
string_at(record, &["outcomeDerived", "status"])
.or_else(|| string_at(record, &["outcome_derived", "status"]))
}
fn string_at(value: &Value, path: &[&str]) -> Option<String> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current.as_str().map(ToOwned::to_owned)
}
fn check_eq(
id: &'static str,
left: Option<&str>,
right: Option<&str>,
description: &str,
) -> CheckReport {
let ok = left.is_some() && right.is_some() && left == right;
let detail = match (left, right) {
(Some(left), Some(right)) if left == right => description.to_string(),
(Some(left), Some(right)) => format!("mismatch: got {left}, expected {right}"),
(None, _) => "missing observed value".to_string(),
(_, None) => "missing expected value".to_string(),
};
CheckReport { id, ok, detail }
}
fn check_enum(id: &'static str, value: Option<&str>, allowed: &[&str]) -> CheckReport {
match value {
Some(value) if allowed.contains(&value) => CheckReport {
id,
ok: true,
detail: format!("{value} is allowed"),
},
Some(value) => CheckReport {
id,
ok: false,
detail: format!("{value} is not one of {}", allowed.join(", ")),
},
None => CheckReport {
id,
ok: false,
detail: "missing value".to_string(),
},
}
}
fn print_table_report(report: &PairingReport) {
println!("MCP Execution Record Pairing Report");
println!("===================================");
println!("OK: {}", if report.ok { "yes" } else { "no" });
println!("Role: {}", report.verification_scope.role);
println!("Attestation: {}", report.attestation.digest);
println!(
"Nonce: {}",
report.attestation.nonce.as_deref().unwrap_or("-")
);
println!(
"Decision: {}",
report.decision.decision.as_deref().unwrap_or("-")
);
if let Some(outcome) = &report.outcome {
println!(
"Outcome: {}",
outcome.status.as_deref().unwrap_or("-")
);
} else {
println!("Outcome: -");
}
println!();
for check in &report.checks {
println!(
"{:<36} {:<4} {}",
check.id,
if check.ok { "ok" } else { "fail" },
check.detail
);
}
println!();
println!("Claims not made: {}", report.claims_not_made.join(", "));
}