use super::jcs;
use super::policy::{ArgsCheck, McpPolicy, UnconstrainedMode};
use crate::fingerprint::sha256_hex;
use hmac::{Hmac, KeyInit, Mac};
use serde_json::{json, Map, Value};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub const SCHEMA: &str = "assay.tool_decision_truth.v0";
const ARGS_DIGEST_DOMAIN: &str = "assay.tool_args.v0";
const SOURCE_CLASSES: &[&str] = &["authoritative_boundary", "reported_trace", "inferred"];
const RESULT_STATUSES: &[&str] = &["ok", "error", "n/a"];
const IDENTITY_STATES: &[&str] = &["present", "absent", "required_missing", "invalid"];
const SECRET_KEY_TOKENS: &[&str] = &[
"token",
"accesstoken",
"refreshtoken",
"authtoken",
"idtoken",
"sessiontoken",
"password",
"passwd",
"apikey",
"secret",
"clientsecret",
"secretkey",
"authorization",
"credential",
"credentials",
"privatekey",
"secretaccesskey",
];
fn normalize_key(key: &str) -> String {
key.chars()
.filter(|c| *c != '_' && *c != '-' && *c != ' ')
.flat_map(|c| c.to_lowercase())
.collect()
}
fn is_secret_key(key: &str) -> bool {
SECRET_KEY_TOKENS.contains(&normalize_key(key).as_str())
}
fn project_args_for_digest(value: &Value) -> Value {
match value {
Value::Object(obj) => {
let mut out = Map::new();
for (k, v) in obj {
if is_secret_key(k) {
continue;
}
out.insert(k.clone(), project_args_for_digest(v));
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(arr.iter().map(project_args_for_digest).collect()),
other => other.clone(),
}
}
fn is_valid_key_id(key_id: &str) -> bool {
!key_id.is_empty()
&& key_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
pub fn args_digest(args: &Value, key: &[u8], key_id: &str) -> Option<String> {
if key.is_empty() || !is_valid_key_id(key_id) {
return None;
}
let proj = project_args_for_digest(args);
let canonical = jcs::to_string(&proj).ok()?;
let preimage = format!("{ARGS_DIGEST_DOMAIN}:{key_id}:{canonical}");
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts a key of any length");
mac.update(preimage.as_bytes());
Some(format!(
"hmac-sha256:{key_id}:{}",
hex::encode(mac.finalize().into_bytes())
))
}
pub fn observed_input_digest(tool_name: &str, args_digest: &str, order: i64) -> Option<String> {
let v = json!({"tool_name": tool_name, "args_digest": args_digest, "order": order});
let canonical = jcs::to_string(&v).ok()?;
Some(format!("sha256:{}", sha256_hex(&canonical)))
}
#[allow(clippy::too_many_arguments)]
pub fn build_record(
tool_name: &str,
args: &Value,
order: i64,
declared_policy_digest: &str,
key: &[u8],
key_id: &str,
source_class: &str,
call_id: &str,
result_status: &str,
identity_state: &str,
) -> Option<Value> {
if !SOURCE_CLASSES.contains(&source_class)
|| !RESULT_STATUSES.contains(&result_status)
|| !IDENTITY_STATES.contains(&identity_state)
{
return None;
}
if !is_sha256_digest(declared_policy_digest) {
return None;
}
let ad = args_digest(args, key, key_id)?;
let oid = observed_input_digest(tool_name, &ad, order)?;
Some(json!({
"schema": SCHEMA,
"tool_name": tool_name,
"args_digest": ad,
"order": order,
"source_class": source_class,
"call_id": call_id,
"result_status": result_status,
"identity_state": identity_state,
"key_id": key_id,
"declared_ref": Value::Null,
"decision_verdict": Value::Null,
"observed_input_digest": oid,
"declared_policy_digest": declared_policy_digest,
"decision_identity": {
"observed_input_digest": oid,
"declared_policy_digest": declared_policy_digest,
},
}))
}
fn verdict_rank(v: &str) -> u8 {
match v {
"match" => 0,
"incomplete" => 1,
"mismatch" => 2,
_ => 3, }
}
fn to_static_verdict(v: &str) -> &'static str {
match v {
"match" => "match",
"incomplete" => "incomplete",
"mismatch" => "mismatch",
_ => "invalid",
}
}
fn enforcement_axis(mode: &UnconstrainedMode) -> &'static str {
match mode {
UnconstrainedMode::Deny => "mismatch",
UnconstrainedMode::Allow => "match",
UnconstrainedMode::Warn => "incomplete",
}
}
fn nonempty(list: &Option<Vec<String>>) -> &[String] {
list.as_deref().unwrap_or(&[])
}
fn tool_axis(policy: &McpPolicy, tool_name: &str) -> &'static str {
let matches = |p: &String| McpPolicy::tool_name_matches_experimental(tool_name, p);
if nonempty(&policy.tools.deny).iter().any(matches) {
return "mismatch";
}
let allow = nonempty(&policy.tools.allow);
if !allow.is_empty() {
return if allow.iter().any(matches) {
"match"
} else {
"mismatch"
};
}
enforcement_axis(&policy.enforcement.unconstrained_tools)
}
fn args_axis(policy: &McpPolicy, tool_name: &str, args: Option<&Value>) -> &'static str {
let Some(args) = args else {
return "incomplete"; };
match policy.check_tool_args_experimental(tool_name, args) {
ArgsCheck::NoSchema => enforcement_axis(&policy.enforcement.unconstrained_tools),
ArgsCheck::Valid => "match",
ArgsCheck::Invalid => "mismatch",
ArgsCheck::Malformed => "invalid",
}
}
fn identity_axis(identity_state: &str) -> &'static str {
match identity_state {
"present" | "absent" => "match", "required_missing" => "incomplete",
_ => "invalid", }
}
fn class_axis(policy: &McpPolicy, tool_classes: Option<&[String]>) -> &'static str {
let deny_c = nonempty(&policy.tools.deny_classes);
let allow_c = nonempty(&policy.tools.allow_classes);
if deny_c.is_empty() && allow_c.is_empty() {
return "match"; }
let Some(tc) = tool_classes else {
return "incomplete"; };
if !deny_c.is_empty() && tc.iter().any(|c| deny_c.contains(c)) {
return "mismatch";
}
if !allow_c.is_empty() && !tc.iter().any(|c| allow_c.contains(c)) {
return "mismatch";
}
"match"
}
enum Applicability {
Applicable,
NotApplicable,
Undeterminable,
}
fn applicability(
names: &[String],
classes: &[String],
tool_name: &str,
tool_classes: Option<&[String]>,
) -> Applicability {
if names
.iter()
.any(|p| McpPolicy::tool_name_matches_experimental(tool_name, p))
{
return Applicability::Applicable;
}
if classes.is_empty() {
return Applicability::NotApplicable; }
match tool_classes {
None => Applicability::Undeterminable,
Some(tc) if tc.iter().any(|c| classes.contains(c)) => Applicability::Applicable,
Some(_) => Applicability::NotApplicable,
}
}
fn obligation_axis(applic: Applicability, satisfied: Option<bool>) -> &'static str {
match applic {
Applicability::NotApplicable => "match",
Applicability::Undeterminable => "incomplete",
Applicability::Applicable => match satisfied {
None => "incomplete",
Some(true) => "match",
Some(false) => "mismatch",
},
}
}
#[derive(Debug, Clone, Default)]
pub struct DecisionEvidence {
pub tool_classes: Option<Vec<String>>,
pub approval_obtained: Option<bool>,
pub scope_satisfied: Option<bool>,
pub redaction_applied: Option<bool>,
}
pub fn decision_verdict(
policy: &McpPolicy,
tool_name: &str,
args: Option<&Value>,
identity_state: &str,
evidence: &DecisionEvidence,
) -> &'static str {
let p = policy.normalized_declared_view_experimental();
let tc = evidence.tool_classes.as_deref();
let approval = applicability(
nonempty(&p.tools.approval_required),
nonempty(&p.tools.approval_required_classes),
tool_name,
tc,
);
let scope = applicability(
nonempty(&p.tools.restrict_scope),
nonempty(&p.tools.restrict_scope_classes),
tool_name,
tc,
);
let redaction = applicability(
nonempty(&p.tools.redact_args),
nonempty(&p.tools.redact_args_classes),
tool_name,
tc,
);
[
tool_axis(&p, tool_name),
args_axis(&p, tool_name, args),
identity_axis(identity_state),
class_axis(&p, tc),
obligation_axis(approval, evidence.approval_obtained),
obligation_axis(scope, evidence.scope_satisfied),
obligation_axis(redaction, evidence.redaction_applied),
]
.into_iter()
.max_by_key(|&v| verdict_rank(v))
.unwrap_or("match")
}
pub fn run_verdict(decision_verdicts: &[&str], orders: &[i64]) -> &'static str {
if decision_verdicts.len() != orders.len() {
return "invalid";
}
let mut seen = std::collections::HashSet::new();
for o in orders {
if !seen.insert(*o) {
return "invalid";
}
}
let mut worst: &'static str = "match";
for v in decision_verdicts {
let s = to_static_verdict(v);
if verdict_rank(s) > verdict_rank(worst) {
worst = s;
}
}
worst
}
#[allow(clippy::too_many_arguments)]
pub fn build_classified_record(
policy: &McpPolicy,
tool_name: &str,
args: &Value,
order: i64,
key: &[u8],
key_id: &str,
source_class: &str,
call_id: &str,
result_status: &str,
identity_state: &str,
evidence: &DecisionEvidence,
) -> Option<Value> {
let declared = policy.declared_constraint_digest_experimental()?;
let mut record = build_record(
tool_name,
args,
order,
&declared,
key,
key_id,
source_class,
call_id,
result_status,
identity_state,
)?;
record["decision_verdict"] = json!(decision_verdict(
policy,
tool_name,
Some(args),
identity_state,
evidence
));
Some(record)
}
pub const RECIPE: &str = "tool_decision_truth.v0";
fn is_sha256_digest(s: &str) -> bool {
match s.strip_prefix("sha256:") {
Some(hex) => hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')),
None => false,
}
}
fn is_verdict(s: &str) -> bool {
matches!(s, "match" | "incomplete" | "mismatch" | "invalid")
}
fn run_verdict_covers_carrier(carrier: &Value, run_verdict: &str) -> bool {
match carrier.get("decision_verdict") {
None | Some(Value::Null) => true,
Some(Value::String(cv)) if is_verdict(cv) => verdict_rank(run_verdict) >= verdict_rank(cv),
_ => false,
}
}
pub fn decision_identity_digest(
observed_input_digest: &str,
declared_policy_digest: &str,
) -> Option<String> {
let identity = json!({
"observed_input_digest": observed_input_digest,
"declared_policy_digest": declared_policy_digest,
});
Some(format!(
"sha256:{}",
sha256_hex(&jcs::to_string(&identity).ok()?)
))
}
pub fn carrier_content_digest(carrier: &Value) -> Option<String> {
Some(format!(
"sha256:{}",
sha256_hex(&jcs::to_string(carrier).ok()?)
))
}
pub fn evidence_ref(carrier_content_digest: &str, reference: &str) -> Value {
json!({
"type": "tool_decision_truth",
"digest": carrier_content_digest,
"digest_subject": "carrier_content",
"canonicalization": "jcs-json-v1",
"schema": SCHEMA,
"ref": reference,
})
}
pub fn pack_recipe_row(carrier: &Value, run_verdict: &str, reference: &str) -> Option<Value> {
if !is_verdict(run_verdict) || !run_verdict_covers_carrier(carrier, run_verdict) {
return None;
}
let oid = carrier
.get("observed_input_digest")
.and_then(|v| v.as_str())?;
let dpd = carrier
.get("declared_policy_digest")
.and_then(|v| v.as_str())?;
if !is_sha256_digest(oid) || !is_sha256_digest(dpd) {
return None;
}
let content = carrier_content_digest(carrier)?;
let identity = decision_identity_digest(oid, dpd)?;
let er = evidence_ref(&content, reference);
let binding_input = json!({
"recipe": RECIPE,
"evidence_ref": er,
"decision_identity_digest": identity,
"run_verdict": run_verdict,
});
let coherence_binding = format!(
"sha256:{}",
sha256_hex(&jcs::to_string(&binding_input).ok()?)
);
Some(json!({
"recipe": RECIPE,
"evidence_ref": er,
"decision_identity_digest": identity,
"run_verdict": run_verdict,
"coherence_binding": coherence_binding,
}))
}
pub fn verify_recipe_row(row: &Value, carrier: &Value, run_verdict: &str) -> bool {
if row.get("recipe").and_then(|r| r.as_str()) != Some(RECIPE) {
return false;
}
let Some(er) = row.get("evidence_ref").cloned() else {
return false;
};
if er.get("type").and_then(|x| x.as_str()) != Some("tool_decision_truth")
|| er.get("schema").and_then(|x| x.as_str()) != Some(SCHEMA)
|| er.get("canonicalization").and_then(|x| x.as_str()) != Some("jcs-json-v1")
|| er.get("digest_subject").and_then(|x| x.as_str()) != Some("carrier_content")
{
return false;
}
if row.get("run_verdict").and_then(|v| v.as_str()) != Some(run_verdict) {
return false;
}
if !is_verdict(run_verdict) || !run_verdict_covers_carrier(carrier, run_verdict) {
return false;
}
let Some(er_digest) = er.get("digest").and_then(|d| d.as_str()) else {
return false;
};
let Some(row_identity) = row.get("decision_identity_digest").and_then(|d| d.as_str()) else {
return false;
};
if !is_sha256_digest(er_digest) || !is_sha256_digest(row_identity) {
return false;
}
match carrier_content_digest(carrier) {
Some(c) if c == er_digest => {}
_ => return false,
}
let (Some(oid), Some(dpd)) = (
carrier
.get("observed_input_digest")
.and_then(|v| v.as_str()),
carrier
.get("declared_policy_digest")
.and_then(|v| v.as_str()),
) else {
return false;
};
if !is_sha256_digest(oid) || !is_sha256_digest(dpd) {
return false;
}
match decision_identity_digest(oid, dpd) {
Some(i) if i == row_identity => {}
_ => return false,
}
let binding_input = json!({
"recipe": RECIPE,
"evidence_ref": er,
"decision_identity_digest": row_identity,
"run_verdict": run_verdict,
});
let expected = match jcs::to_string(&binding_input) {
Ok(s) => format!("sha256:{}", sha256_hex(&s)),
Err(_) => return false,
};
row.get("coherence_binding").and_then(|c| c.as_str()) == Some(expected.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &[u8] = b"reference-test-key-v0";
const KID: &str = "test-key-v0";
const PROV: (&str, &str, &str, &str) = ("authoritative_boundary", "c1", "ok", "present");
const DECL: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111";
fn ad(args: Value) -> String {
args_digest(&args, KEY, KID).unwrap()
}
fn rec(tool: &str, args: Value, order: i64, prov: (&str, &str, &str, &str)) -> Value {
build_record(
tool, &args, order, DECL, KEY, KID, prov.0, prov.1, prov.2, prov.3,
)
.unwrap()
}
#[test]
fn args_digest_drops_secret_fields_recursively() {
assert_eq!(
ad(json!({"path": "/x", "token": "aaa"})),
ad(json!({"path": "/x", "token": "bbb"}))
);
assert_eq!(
ad(json!({"path": "/x", "token": "aaa"})),
ad(json!({"path": "/x"}))
);
assert_eq!(
ad(json!({"cfg": {"host": "h", "token": "aaa"}})),
ad(json!({"cfg": {"host": "h", "token": "bbb"}}))
);
assert_eq!(
ad(json!({"cfg": {"host": "h", "token": "aaa"}})),
ad(json!({"cfg": {"host": "h"}}))
);
assert_eq!(
ad(json!({"items": [{"password": "p1"}, {"k": "v"}]})),
ad(json!({"items": [{"password": "p2"}, {"k": "v"}]}))
);
}
#[test]
fn args_digest_carries_key_id() {
assert!(ad(json!({"path": "/x"})).starts_with("hmac-sha256:test-key-v0:"));
}
#[test]
fn secret_classifier_catches_case_and_separator_variants() {
let base = ad(json!({"path": "/x"}));
for secret in [
"Authorization",
"authorization",
"client_secret",
"clientSecret",
"refresh_token",
"refreshToken",
"ACCESS_TOKEN",
"access-token",
"apiKey",
"api-key",
"privateKey",
"secretAccessKey",
] {
let mut withv = serde_json::Map::new();
withv.insert("path".into(), json!("/x"));
withv.insert(secret.into(), json!("super-secret-value"));
assert_eq!(
ad(Value::Object(withv)),
base,
"secret-named key `{secret}` must be dropped before the digest"
);
}
}
#[test]
fn non_secret_key_still_contributes() {
assert_ne!(ad(json!({"account": "a"})), ad(json!({"account": "b"})));
}
#[test]
fn args_digest_rejects_empty_key_and_bad_key_id() {
assert!(args_digest(&json!({"path": "/x"}), b"", KID).is_none());
assert!(args_digest(&json!({"path": "/x"}), KEY, "").is_none());
assert!(args_digest(&json!({"path": "/x"}), KEY, "bad id").is_none()); assert!(args_digest(&json!({"path": "/x"}), KEY, "bad:id").is_none()); assert!(args_digest(&json!({"path": "/x"}), KEY, "good.key_id-v0").is_some());
}
#[test]
fn build_record_rejects_out_of_contract_provenance() {
let args = json!({"path": "/x"});
let mk = |sc: &str, rs: &str, id: &str| {
build_record("read_file", &args, 0, DECL, KEY, KID, sc, "c1", rs, id)
};
assert!(mk("authoritative_boundary", "ok", "present").is_some());
assert!(mk("made_up_source", "ok", "present").is_none());
assert!(mk("authoritative_boundary", "maybe", "present").is_none());
assert!(mk("authoritative_boundary", "ok", "unknown_state").is_none());
}
#[test]
fn build_record_rejects_malformed_declared_digest() {
let args = json!({"path": "/x"});
let mk = |decl: &str| {
build_record(
"read_file",
&args,
0,
decl,
KEY,
KID,
"authoritative_boundary",
"c1",
"ok",
"present",
)
};
assert!(mk(DECL).is_some()); assert!(mk("sha256:decl").is_none()); assert!(mk("not-a-digest").is_none()); }
#[test]
fn low_entropy_arg_not_recoverable_without_the_key() {
let truth = args_digest(&json!({"admin": true}), KEY, KID);
let space = [json!({"admin": true}), json!({"admin": false})];
assert!(space.iter().any(|c| args_digest(c, KEY, KID) == truth));
assert!(!space
.iter()
.any(|c| args_digest(c, b"attacker-guess", KID) == truth));
}
#[test]
fn identity_stable_under_provenance_changes() {
let a = rec("read_file", json!({"path": "/a"}), 0, PROV);
let b = rec(
"read_file",
json!({"path": "/a"}),
0,
("reported_trace", "c2", "error", "absent"),
);
assert_eq!(a["observed_input_digest"], b["observed_input_digest"]);
assert_eq!(a["decision_identity"], b["decision_identity"]);
}
#[test]
fn identity_changes_with_observed_inputs() {
let base = rec("read_file", json!({"path": "/a"}), 0, PROV);
let oid = &base["observed_input_digest"];
assert_ne!(
oid,
&rec("list_dir", json!({"path": "/a"}), 0, PROV)["observed_input_digest"]
);
assert_ne!(
oid,
&rec("read_file", json!({"path": "/b"}), 0, PROV)["observed_input_digest"]
);
assert_ne!(
oid,
&rec("read_file", json!({"path": "/a"}), 5, PROV)["observed_input_digest"]
);
}
#[test]
fn record_never_carries_raw_args() {
let r = rec(
"read_file",
json!({"path": "/a", "token": "secret"}),
0,
PROV,
);
assert!(r.get("args").is_none() && r.get("arguments").is_none());
assert!(r["args_digest"]
.as_str()
.unwrap()
.starts_with("hmac-sha256:"));
}
#[test]
fn decision_identity_is_the_two_digests_only() {
let r = rec("read_file", json!({"path": "/a"}), 0, PROV);
let di = r["decision_identity"].as_object().unwrap();
let mut keys: Vec<&String> = di.keys().collect();
keys.sort();
assert_eq!(
keys,
vec!["declared_policy_digest", "observed_input_digest"]
);
}
}
#[cfg(test)]
mod gate_tests {
use super::*;
const KEY: &[u8] = b"k";
const KID: &str = "kid";
fn policy() -> McpPolicy {
serde_json::from_value(json!({
"version": "1",
"tools": {"allow": ["read_file", "deploy"], "deny": ["delete_all"]},
"schemas": {"deploy": {"type": "object", "required": ["env"],
"properties": {"env": {"enum": ["staging", "prod"]}}}},
"enforcement": {"unconstrained_tools": "warn"}
}))
.unwrap()
}
fn v(tool: &str, args: Option<Value>, id: &str) -> &'static str {
decision_verdict(
&policy(),
tool,
args.as_ref(),
id,
&DecisionEvidence::default(),
)
}
fn p_from(v: Value) -> McpPolicy {
serde_json::from_value(v).unwrap()
}
#[test]
fn per_decision_verdict_matrix() {
assert_eq!(
v("deploy", Some(json!({"env": "staging"})), "present"),
"match"
);
assert_eq!(v("delete_all", Some(json!({})), "present"), "mismatch");
assert_eq!(v("exfiltrate", Some(json!({})), "present"), "mismatch");
assert_eq!(
v("deploy", Some(json!({"env": "dev"})), "present"),
"mismatch"
);
assert_eq!(v("deploy", None, "present"), "incomplete");
assert_eq!(
v("read_file", Some(json!({"path": "/x"})), "present"),
"incomplete"
);
assert_eq!(
v(
"deploy",
Some(json!({"env": "staging"})),
"required_missing"
),
"incomplete"
);
assert_eq!(
v("deploy", Some(json!({"env": "staging"})), "invalid"),
"invalid"
);
}
#[test]
fn absent_identity_does_not_block_match() {
assert_eq!(v("deploy", Some(json!({"env": "prod"})), "absent"), "match");
}
#[test]
fn tool_axis_uses_engine_pattern_semantics() {
let p = p_from(json!({
"version": "1",
"tools": {"deny": ["delete_*"]},
"enforcement": {"unconstrained_tools": "allow"}
}));
let e = DecisionEvidence::default();
assert_eq!(
decision_verdict(&p, "delete_all", Some(&json!({})), "present", &e),
"mismatch"
);
assert_eq!(
decision_verdict(&p, "read_file", Some(&json!({})), "present", &e),
"match"
);
}
#[test]
fn declared_constraint_without_evidence_is_incomplete_not_match() {
let p = p_from(json!({
"version": "1",
"tools": {"allow": ["pay"], "approval_required": ["pay"]},
"enforcement": {"unconstrained_tools": "allow"}
}));
assert_eq!(
decision_verdict(
&p,
"pay",
Some(&json!({})),
"present",
&DecisionEvidence::default()
),
"incomplete"
);
let approved = DecisionEvidence {
approval_obtained: Some(true),
..Default::default()
};
assert_eq!(
decision_verdict(&p, "pay", Some(&json!({})), "present", &approved),
"match"
);
let denied = DecisionEvidence {
approval_obtained: Some(false),
..Default::default()
};
assert_eq!(
decision_verdict(&p, "pay", Some(&json!({})), "present", &denied),
"mismatch"
);
}
#[test]
fn class_axis_needs_class_evidence() {
let p = p_from(json!({
"version": "1",
"tools": {"allow": ["x"], "deny_classes": ["network"]},
"enforcement": {"unconstrained_tools": "allow"}
}));
assert_eq!(
decision_verdict(
&p,
"x",
Some(&json!({})),
"present",
&DecisionEvidence::default()
),
"incomplete"
);
let net = DecisionEvidence {
tool_classes: Some(vec!["network".into()]),
..Default::default()
};
assert_eq!(
decision_verdict(&p, "x", Some(&json!({})), "present", &net),
"mismatch"
);
let fs = DecisionEvidence {
tool_classes: Some(vec!["fs".into()]),
..Default::default()
};
assert_eq!(
decision_verdict(&p, "x", Some(&json!({})), "present", &fs),
"match"
);
}
#[test]
fn malformed_declared_schema_is_invalid_not_panic() {
let p = p_from(json!({
"version": "1",
"tools": {"allow": ["t"]},
"schemas": {"t": {"$ref": "#/$defs/missing"}},
"enforcement": {"unconstrained_tools": "allow"}
}));
assert_eq!(
p.check_tool_args_experimental("t", &json!({})),
ArgsCheck::Malformed
);
assert_eq!(
decision_verdict(
&p,
"t",
Some(&json!({})),
"present",
&DecisionEvidence::default()
),
"invalid"
);
}
#[test]
fn legacy_constraints_are_evaluated_by_the_verdict() {
let p = p_from(json!({
"version": "1",
"tools": {"allow": ["deploy"]},
"constraints": [{"tool": "deploy", "params": {"env": {"matches": "^prod$"}}}],
"enforcement": {"unconstrained_tools": "allow"}
}));
let e = DecisionEvidence::default();
assert_eq!(
decision_verdict(&p, "deploy", Some(&json!({"env": "prod"})), "present", &e),
"match"
);
assert_eq!(
decision_verdict(
&p,
"deploy",
Some(&json!({"env": "staging"})),
"present",
&e
),
"mismatch"
);
}
#[test]
fn run_lattice_and_order_integrity() {
assert_eq!(run_verdict(&["match", "incomplete"], &[0, 1]), "incomplete");
assert_eq!(run_verdict(&["match", "mismatch"], &[0, 1]), "mismatch");
assert_eq!(
run_verdict(&["match", "mismatch", "invalid"], &[0, 1, 2]),
"invalid"
);
assert_eq!(run_verdict(&["match", "match"], &[0, 0]), "invalid"); assert_eq!(run_verdict(&["match"], &[0]), "match");
assert_eq!(run_verdict(&["match", "match"], &[0]), "invalid");
assert_eq!(run_verdict(&["match"], &[0, 1]), "invalid");
}
#[test]
fn build_classified_record_carries_the_verdict() {
let e = DecisionEvidence::default();
let m = build_classified_record(
&policy(),
"deploy",
&json!({"env": "staging"}),
0,
KEY,
KID,
"authoritative_boundary",
"c1",
"ok",
"present",
&e,
)
.unwrap();
assert_eq!(m["decision_verdict"], json!("match"));
let mm = build_classified_record(
&policy(),
"delete_all",
&json!({}),
0,
KEY,
KID,
"authoritative_boundary",
"c1",
"ok",
"present",
&e,
)
.unwrap();
assert_eq!(mm["decision_verdict"], json!("mismatch"));
}
}
#[cfg(test)]
mod pack_tests {
use super::*;
const REF: &str = "audit://decision/c1";
fn pack_policy(allow: &str, deny: &str) -> McpPolicy {
serde_json::from_value(json!({
"version": "1",
"tools": {"allow": [allow], "deny": [deny]},
"schemas": {"deploy": {"type": "object", "required": ["env"],
"properties": {"env": {"enum": ["staging", "prod"]}}}},
"enforcement": {"unconstrained_tools": "warn"}
}))
.unwrap()
}
fn carrier() -> Value {
build_classified_record(
&pack_policy("deploy", "delete_all"),
"deploy",
&json!({"env": "prod"}),
0,
b"reference-test-key-v0",
"test-key-v0",
"authoritative_boundary",
"c1",
"ok",
"present",
&DecisionEvidence::default(),
)
.unwrap()
}
#[test]
fn evidence_ref_has_the_envelope_fields() {
let cd = carrier_content_digest(&carrier()).unwrap();
let er = evidence_ref(&cd, REF);
let mut keys: Vec<&String> = er.as_object().unwrap().keys().collect();
keys.sort();
assert_eq!(
keys,
vec![
"canonicalization",
"digest",
"digest_subject",
"ref",
"schema",
"type"
]
);
assert_eq!(er["schema"], json!(SCHEMA));
assert_eq!(er["digest"], json!(cd));
assert_eq!(er["digest_subject"], json!("carrier_content"));
}
#[test]
fn content_and_identity_digests_are_distinct_and_well_formed() {
let c = carrier();
let content = carrier_content_digest(&c).unwrap();
let identity = decision_identity_digest(
c["observed_input_digest"].as_str().unwrap(),
c["declared_policy_digest"].as_str().unwrap(),
)
.unwrap();
assert_ne!(content, identity);
assert!(super::is_sha256_digest(&content));
assert!(super::is_sha256_digest(&identity));
}
#[test]
fn recipe_row_coheres_with_its_real_carrier() {
let c = carrier();
let row = pack_recipe_row(&c, "match", REF).unwrap();
assert!(verify_recipe_row(&row, &c, "match"));
assert_eq!(
row["evidence_ref"]["digest"],
json!(carrier_content_digest(&c).unwrap())
);
assert!(row.get("decision_identity_digest").is_some());
}
#[test]
fn tampering_any_carrier_field_or_the_verdict_fails_closed() {
let c = carrier();
let row = pack_recipe_row(&c, "match", REF).unwrap();
let mut tampered = c.clone();
tampered["result_status"] = json!("error");
assert!(!verify_recipe_row(&row, &tampered, "match"));
let mut tampered_id = c.clone();
tampered_id["observed_input_digest"] = json!("sha256:deadbeef");
assert!(!verify_recipe_row(&row, &tampered_id, "match"));
assert!(!verify_recipe_row(&row, &c, "mismatch"));
}
fn coherent_row(recipe: &str, er: Value, identity: &str, run_verdict: &str) -> Value {
let binding_input = json!({
"recipe": recipe,
"evidence_ref": er,
"decision_identity_digest": identity,
"run_verdict": run_verdict,
});
let cb = format!(
"sha256:{}",
crate::fingerprint::sha256_hex(&crate::mcp::jcs::to_string(&binding_input).unwrap())
);
json!({
"recipe": recipe,
"evidence_ref": er,
"decision_identity_digest": identity,
"run_verdict": run_verdict,
"coherence_binding": cb,
})
}
#[test]
fn verify_rejects_foreign_recipe_or_envelope_even_when_self_coherent() {
let c = carrier();
let content = carrier_content_digest(&c).unwrap();
let identity = decision_identity_digest(
c["observed_input_digest"].as_str().unwrap(),
c["declared_policy_digest"].as_str().unwrap(),
)
.unwrap();
let good_er = evidence_ref(&content, REF);
assert!(verify_recipe_row(
&coherent_row(RECIPE, good_er.clone(), &identity, "match"),
&c,
"match"
));
assert!(!verify_recipe_row(
&coherent_row("other.recipe.v0", good_er.clone(), &identity, "match"),
&c,
"match"
));
for (field, bad) in [
("type", json!("other")),
("schema", json!("other.schema.v0")),
("canonicalization", json!("cbor-deterministic-v1")),
("digest_subject", json!("decision_identity")),
] {
let mut er = good_er.as_object().unwrap().clone();
er.insert(field.to_string(), bad);
let row = coherent_row(RECIPE, Value::Object(er), &identity, "match");
assert!(
!verify_recipe_row(&row, &c, "match"),
"envelope field {field} must be rejected"
);
}
}
#[test]
fn verify_rejects_malformed_citation_digests() {
let c = carrier();
let identity = decision_identity_digest(
c["observed_input_digest"].as_str().unwrap(),
c["declared_policy_digest"].as_str().unwrap(),
)
.unwrap();
let bad_er = evidence_ref("sha256:short", REF);
assert!(!verify_recipe_row(
&coherent_row(RECIPE, bad_er, &identity, "match"),
&c,
"match"
));
let good_er = evidence_ref(&carrier_content_digest(&c).unwrap(), REF);
assert!(!verify_recipe_row(
&coherent_row(RECIPE, good_er, "not-a-digest", "match"),
&c,
"match"
));
}
#[test]
fn verify_rejects_carrier_with_malformed_embedded_digest() {
let mut bad = carrier();
bad["observed_input_digest"] = json!("sha256:bad"); let content = carrier_content_digest(&bad).unwrap();
let identity = decision_identity_digest(
"sha256:bad",
bad["declared_policy_digest"].as_str().unwrap(),
)
.unwrap();
let row = coherent_row(RECIPE, evidence_ref(&content, REF), &identity, "match");
assert!(!verify_recipe_row(&row, &bad, "match"));
}
#[test]
fn pack_row_rejects_bogus_or_understated_verdict() {
let c = carrier();
assert_eq!(c["decision_verdict"], json!("match"));
assert!(pack_recipe_row(&c, "approved", REF).is_none());
let mismatch_carrier = build_classified_record(
&pack_policy("deploy", "delete_all"),
"delete_all",
&json!({}),
0,
b"reference-test-key-v0",
"test-key-v0",
"authoritative_boundary",
"c1",
"ok",
"present",
&DecisionEvidence::default(),
)
.unwrap();
assert_eq!(mismatch_carrier["decision_verdict"], json!("mismatch"));
assert!(pack_recipe_row(&mismatch_carrier, "match", REF).is_none()); let row = pack_recipe_row(&mismatch_carrier, "mismatch", REF).unwrap(); assert!(verify_recipe_row(&row, &mismatch_carrier, "mismatch"));
let content = carrier_content_digest(&mismatch_carrier).unwrap();
let identity = decision_identity_digest(
mismatch_carrier["observed_input_digest"].as_str().unwrap(),
mismatch_carrier["declared_policy_digest"].as_str().unwrap(),
)
.unwrap();
let understating = coherent_row(RECIPE, evidence_ref(&content, REF), &identity, "match");
assert!(!verify_recipe_row(
&understating,
&mismatch_carrier,
"match"
));
let mut malformed = carrier();
malformed["decision_verdict"] = json!("approved");
assert!(pack_recipe_row(&malformed, "match", REF).is_none());
assert!(pack_recipe_row(&malformed, "invalid", REF).is_none());
}
}