use anyhow::Context;
use assay_core::mcp::proxy::TdtProducer;
use std::path::PathBuf;
pub(super) fn build_tdt_producer(out_path: PathBuf) -> anyhow::Result<TdtProducer> {
const KEY_VAR: &str = "ASSAY_TDT_HMAC_KEY";
const KEY_ID_VAR: &str = "ASSAY_TDT_HMAC_KEY_ID";
let key = std::env::var(KEY_VAR).ok();
let key_id = std::env::var(KEY_ID_VAR).ok();
std::env::remove_var(KEY_VAR);
std::env::remove_var(KEY_ID_VAR);
tdt_producer_from_material(out_path, key, key_id)
}
fn tdt_producer_from_material(
out_path: PathBuf,
key: Option<String>,
key_id: Option<String>,
) -> anyhow::Result<TdtProducer> {
let key = key.ok_or_else(|| {
anyhow::anyhow!(
"--tool-decision-truth-out is set but ASSAY_TDT_HMAC_KEY is missing; the tool-decision-truth producer fails closed. Set the HMAC key in the environment (never on the command line)."
)
})?;
let key_id = key_id.ok_or_else(|| {
anyhow::anyhow!(
"--tool-decision-truth-out is set but ASSAY_TDT_HMAC_KEY_ID is missing; the tool-decision-truth producer fails closed."
)
})?;
if key.is_empty() {
anyhow::bail!(
"ASSAY_TDT_HMAC_KEY is empty; the tool-decision-truth producer fails closed."
);
}
let key_id_ok = !key_id.is_empty()
&& key_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
if !key_id_ok {
anyhow::bail!(
"ASSAY_TDT_HMAC_KEY_ID is empty or malformed (allowed characters: A-Z a-z 0-9 . _ -); the tool-decision-truth producer fails closed."
);
}
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&out_path)
.with_context(|| {
format!(
"tool-decision-truth sink is not writable at {}; the producer fails closed",
out_path.display()
)
})?;
Ok(TdtProducer::new(out_path, key.into_bytes(), key_id))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[test]
fn tdt_producer_fails_closed_when_key_missing() {
let err = tdt_producer_from_material(
PathBuf::from("/tmp/carriers.ndjson"),
None,
Some("kid-v0".into()),
)
.unwrap_err()
.to_string();
assert!(err.contains("ASSAY_TDT_HMAC_KEY is missing"), "got: {err}");
}
#[test]
fn tdt_producer_fails_closed_when_key_empty() {
let err = tdt_producer_from_material(
PathBuf::from("/tmp/carriers.ndjson"),
Some(String::new()),
Some("kid-v0".into()),
)
.unwrap_err()
.to_string();
assert!(err.contains("ASSAY_TDT_HMAC_KEY is empty"), "got: {err}");
}
#[test]
fn tdt_producer_fails_closed_when_key_id_missing() {
let err = tdt_producer_from_material(
PathBuf::from("/tmp/carriers.ndjson"),
Some("k".into()),
None,
)
.unwrap_err()
.to_string();
assert!(
err.contains("ASSAY_TDT_HMAC_KEY_ID is missing"),
"got: {err}"
);
}
#[test]
fn tdt_producer_fails_closed_when_key_id_malformed() {
let err = tdt_producer_from_material(
PathBuf::from("/tmp/carriers.ndjson"),
Some("k".into()),
Some("bad:id".into()),
)
.unwrap_err()
.to_string();
assert!(err.contains("malformed"), "got: {err}");
}
#[test]
fn tdt_producer_accepts_valid_material_and_debug_redacts_key() {
let dir = tempdir().unwrap();
let producer = tdt_producer_from_material(
dir.path().join("carriers.ndjson"),
Some("super-secret-key".into()),
Some("kid-v0".into()),
)
.expect("valid material");
let dbg = format!("{producer:?}");
assert!(
dbg.contains("<redacted>"),
"Debug must redact the key: {dbg}"
);
assert!(
!dbg.contains("super-secret-key"),
"Debug must not leak the key: {dbg}"
);
}
#[test]
fn tdt_producer_fails_closed_when_sink_cannot_be_opened() {
let dir = tempdir().unwrap();
let out = dir.path().join("missing-parent").join("carriers.ndjson");
let err =
tdt_producer_from_material(out, Some("super-secret-key".into()), Some("kid-v0".into()))
.unwrap_err()
.to_string();
assert!(
err.contains("tool-decision-truth sink is not writable"),
"got: {err}"
);
}
#[test]
fn producer_carrier_line_roundtrips_import_verify_project() {
use crate::cli::args::ProjectOtelArgs;
use crate::cli::commands::evidence::tool_decision_truth::{
cmd_tool_decision_truth, ToolDecisionTruthArgs,
};
use crate::cli::commands::evidence::verify_tool_decision_truth::{
cmd_verify_tool_decision_truth, VerifyFormat, VerifyToolDecisionTruthArgs,
};
use crate::cli::commands::project_otel;
use assay_core::mcp::policy::McpPolicy;
use assay_core::mcp::tool_decision_truth as tdt_core;
use assay_core::mcp::tool_decision_truth::DecisionEvidence;
let policy: McpPolicy = serde_json::from_value(json!({
"version": "1",
"tools": {"allow": ["deploy"], "deny": ["delete_all"]},
"schemas": {"deploy": {"type": "object", "required": ["env"],
"properties": {"env": {"enum": ["staging", "prod"]}}}},
"enforcement": {"unconstrained_tools": "warn"}
}))
.unwrap();
let carrier = tdt_core::build_classified_record(
&policy,
"deploy",
&json!({"env": "prod", "trace": "ZZSENTINELRAWZZ"}),
0,
b"producer-test-key-v0",
"fixture-kid-v0",
"authoritative_boundary",
"call-0",
"ok",
"present",
&DecisionEvidence::default(),
)
.expect("carrier builds");
let dir = tempdir().unwrap();
let sink = dir.path().join("carriers.ndjson");
std::fs::write(
&sink,
format!("{}\n", serde_json::to_string(&carrier).unwrap()),
)
.unwrap();
let body = std::fs::read_to_string(&sink).unwrap();
let line = body.lines().next().unwrap();
let carrier_json = dir.path().join("carrier.json");
std::fs::write(&carrier_json, line).unwrap();
let bundle = dir.path().join("tdt.tar.gz");
let code = cmd_tool_decision_truth(ToolDecisionTruthArgs {
carrier: carrier_json,
bundle_out: bundle.clone(),
run_id: "producer-roundtrip".to_string(),
import_time: Some("2026-06-19T00:00:00Z".to_string()),
})
.expect("import runs");
assert_eq!(code, 0, "import of a producer carrier line should succeed");
let vcode = cmd_verify_tool_decision_truth(VerifyToolDecisionTruthArgs {
bundle: bundle.clone(),
format: VerifyFormat::Json,
})
.expect("verify runs");
assert_eq!(
vcode, 0,
"verify should report ok for a producer-emitted carrier"
);
let proj = dir.path().join("projection.json");
let pcode = project_otel::run(ProjectOtelArgs {
capability_surface: None,
evidence_bundle: Some(bundle),
observation_health: None,
enforcement_health: None,
out: Some(proj.clone()),
})
.expect("project runs");
assert_eq!(pcode, 0, "projection over verified evidence should succeed");
let projection = std::fs::read_to_string(&proj).unwrap();
assert!(
projection.contains("assay.tdt."),
"projection should carry tdt identity attributes: {projection}"
);
assert!(
!projection.contains("ZZSENTINELRAWZZ"),
"projection must not carry raw arguments"
);
}
}