use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
use crate::merkle::{MerkleTree, InclusionProof};
use super::event::SessionEvent;
use super::graph::AgentGraph;
use super::manifest::{
HostInfo, LifecycleMode, Participants, SessionManifest, SessionStatus, ToolInfo,
};
use super::render::RenderConfig;
use super::side_effects::SideEffects;
pub const RECEIPT_TYPE: &str = "treeship/session-receipt/v1";
pub const RECEIPT_SCHEMA_VERSION: &str = "1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionReceipt {
#[serde(rename = "type")]
pub type_: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_version: Option<String>,
pub session: SessionSection,
pub participants: Participants,
pub hosts: Vec<HostInfo>,
pub tools: Vec<ToolInfo>,
pub agent_graph: AgentGraph,
pub timeline: Vec<TimelineEntry>,
pub side_effects: SideEffects,
pub artifacts: Vec<ArtifactEntry>,
pub proofs: ProofsSection,
pub merkle: MerkleSection,
pub render: RenderConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_usage: Option<ToolUsage>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolUsage {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub declared: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actual: Vec<ToolUsageEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unauthorized: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolUsageEntry {
pub tool_name: String,
pub count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSection {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub mode: LifecycleMode,
pub started_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<String>,
pub status: SessionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ship_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narrative: Option<Narrative>,
#[serde(default)]
pub total_tokens_in: u64,
#[serde(default)]
pub total_tokens_out: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Narrative {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headline: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineEntry {
pub sequence_no: u64,
pub timestamp: String,
pub event_id: String,
pub event_type: String,
pub agent_instance_id: String,
pub agent_name: String,
pub host_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactEntry {
pub artifact_id: String,
pub payload_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signed_at: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProofsSection {
#[serde(default)]
pub signature_count: u32,
#[serde(default)]
pub signatures_valid: bool,
#[serde(default)]
pub merkle_root_valid: bool,
#[serde(default)]
pub inclusion_proofs_count: u32,
#[serde(default)]
pub zk_proofs_present: bool,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub event_log_skipped: u32,
}
fn is_zero_u32(n: &u32) -> bool { *n == 0 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MerkleSection {
pub leaf_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checkpoint_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inclusion_proofs: Vec<InclusionProofEntry>,
#[serde(default = "crate::merkle::tree::default_merkle_version_v1")]
pub merkle_version: u8,
}
impl Default for MerkleSection {
fn default() -> Self {
Self {
leaf_count: 0,
root: None,
checkpoint_id: None,
inclusion_proofs: Vec::new(),
merkle_version: crate::merkle::tree::MERKLE_VERSION_V2,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InclusionProofEntry {
pub artifact_id: String,
pub leaf_index: usize,
pub proof: InclusionProof,
}
pub struct ReceiptComposer;
impl ReceiptComposer {
pub fn compose(
manifest: &SessionManifest,
events: &[SessionEvent],
artifact_entries: Vec<ArtifactEntry>,
) -> SessionReceipt {
let agent_graph = AgentGraph::from_events(events);
let side_effects = SideEffects::from_events(events);
let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
TimelineEntry {
sequence_no: e.sequence_no,
timestamp: e.timestamp.clone(),
event_id: e.event_id.clone(),
event_type: event_type_label(&e.event_type),
agent_instance_id: e.agent_instance_id.clone(),
agent_name: e.agent_name.clone(),
host_id: e.host_id.clone(),
summary: event_summary(&e.event_type),
}
}).collect();
timeline.sort_by(|a, b| {
a.timestamp.cmp(&b.timestamp)
.then(a.sequence_no.cmp(&b.sequence_no))
.then(a.event_id.cmp(&b.event_id))
});
let participants = compute_participants(&agent_graph, manifest);
let hosts = compute_hosts(events, &manifest.hosts);
let tools = compute_tools(events, &manifest.tools);
let duration_ms = events.iter().find_map(|e| {
if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
*duration_ms
} else {
None
}
});
let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
let proofs = ProofsSection {
signature_count: artifact_entries.len() as u32,
signatures_valid: true, merkle_root_valid: merkle_tree.is_some(),
inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
zk_proofs_present: false,
event_log_skipped: 0, };
let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
let session = SessionSection {
id: manifest.session_id.clone(),
name: manifest.name.clone(),
mode: manifest.mode.clone(),
started_at: manifest.started_at.clone(),
ended_at: manifest.closed_at.clone(),
status: manifest.status.clone(),
duration_ms,
ship_id: parse_ship_id_from_actor(&manifest.actor),
narrative: manifest.summary.as_ref().map(|s| Narrative {
headline: manifest.name.clone(),
summary: Some(s.clone()),
review: None,
}),
total_tokens_in,
total_tokens_out,
};
let render = RenderConfig {
title: manifest.name.clone(),
theme: None,
sections: RenderConfig::default_sections(),
generate_preview: true,
};
let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
SessionReceipt {
type_: RECEIPT_TYPE.into(),
schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
session,
participants,
hosts,
tools,
agent_graph,
timeline,
side_effects,
artifacts: artifact_entries,
proofs,
merkle: merkle_section,
render,
tool_usage,
}
}
pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
serde_json::to_vec(receipt)
}
pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
let bytes = Self::to_canonical_json(receipt)?;
let hash = Sha256::digest(&bytes);
Ok(format!("sha256:{}", hex::encode(hash)))
}
}
fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
use std::collections::BTreeSet;
let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
let total_agents = graph.nodes.len() as u32;
let spawned_subagents = graph.spawn_count();
let handoffs = graph.handoff_count();
let max_depth = graph.max_depth();
let host_ids = graph.host_ids();
for tool in &manifest.tools {
if let Some(ref rt) = tool.tool_runtime_id {
tool_runtimes.insert(rt.clone());
}
}
let root = graph.nodes.iter()
.filter(|n| n.depth == 0)
.min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
.map(|n| n.agent_instance_id.clone());
let final_output = graph.nodes.iter()
.filter(|n| n.completed_at.is_some())
.max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
.map(|n| n.agent_instance_id.clone());
Participants {
root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
total_agents,
spawned_subagents,
handoffs,
max_depth,
hosts: host_ids.len() as u32,
tool_runtimes: tool_runtimes.len() as u32,
}
}
fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
use std::collections::BTreeMap;
let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
for h in manifest_hosts {
hosts.insert(h.host_id.clone(), h.clone());
}
for e in events {
hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
host_id: e.host_id.clone(),
hostname: None,
os: None,
arch: None,
});
}
hosts.into_values().collect()
}
fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
use std::collections::BTreeMap;
let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
for t in manifest_tools {
tools.insert(t.tool_id.clone(), t.clone());
}
for e in events {
if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
tool_id: tool_name.clone(),
tool_name: tool_name.clone(),
tool_runtime_id: e.tool_runtime_id.clone(),
invocation_count: 0,
});
entry.invocation_count += 1;
}
}
tools.into_values().collect()
}
fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
if artifacts.is_empty() {
return (MerkleSection::default(), None);
}
let mut tree = MerkleTree::new();
for art in artifacts {
tree.append(&art.artifact_id);
}
let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
.filter_map(|(i, art)| {
tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
artifact_id: art.artifact_id.clone(),
leaf_index: i,
proof,
})
})
.collect();
let section = MerkleSection {
leaf_count: artifacts.len(),
root,
checkpoint_id: None,
inclusion_proofs,
merkle_version: tree.version(),
};
(section, Some(tree))
}
pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
let rest = actor.strip_prefix("ship://")?;
let id = rest.split('/').next().unwrap_or(rest);
if id.is_empty() { None } else { Some(id.to_string()) }
}
const TOOL_ALIASES: &[(&str, &[&str])] = &[
("read_file", &["read_file", "Read"]),
("write_file", &["write_file", "Write", "Edit", "MultiEdit", "NotebookEdit", "edit_file"]),
("bash", &["bash", "Bash", "shell"]),
("web_fetch", &["web_fetch", "WebFetch", "webfetch"]),
];
fn source_attributes_a_tool(source: Option<&str>) -> bool {
matches!(
source,
None
| Some("hook")
| Some("mcp")
| Some("shell-wrap")
| Some("session-event-cli"),
)
}
fn count_attributed<'a, F>(
items: usize,
source_at: F,
canonical: &str,
counts: &mut std::collections::BTreeMap<String, u32>,
)
where
F: Fn(usize) -> Option<&'a str>,
{
let n: u32 = (0..items)
.filter(|i| source_attributes_a_tool(source_at(*i)))
.count() as u32;
if n > 0 {
*counts.entry(canonical.to_string()).or_insert(0) += n;
}
}
fn derive_tool_usage(
side_effects: &SideEffects,
authorized_tools: &[String],
) -> Option<ToolUsage> {
use std::collections::BTreeMap;
let total_specialized = side_effects.files_read.len()
+ side_effects.files_written.len()
+ side_effects.processes.len()
+ side_effects.network_connections.len();
if side_effects.tool_invocations.is_empty()
&& total_specialized == 0
&& authorized_tools.is_empty()
{
return None;
}
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
for inv in &side_effects.tool_invocations {
*counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
}
let fr = &side_effects.files_read;
count_attributed(
fr.len(),
|i| fr[i].source.as_deref(),
"read_file",
&mut counts,
);
let fw = &side_effects.files_written;
count_attributed(
fw.len(),
|i| fw[i].source.as_deref(),
"write_file",
&mut counts,
);
let pr = &side_effects.processes;
count_attributed(
pr.len(),
|i| pr[i].source.as_deref(),
"bash",
&mut counts,
);
if !side_effects.network_connections.is_empty() {
*counts.entry("web_fetch".to_string()).or_insert(0) +=
side_effects.network_connections.len() as u32;
}
let actual: Vec<ToolUsageEntry> = counts.iter()
.map(|(name, &count)| ToolUsageEntry { tool_name: name.clone(), count })
.collect();
let unauthorized = if authorized_tools.is_empty() {
Vec::new()
} else {
let declared_set: std::collections::BTreeSet<&str> = authorized_tools.iter()
.map(|s| s.as_str())
.collect();
counts.keys()
.filter(|actual_name| !is_authorized(actual_name, &declared_set))
.cloned()
.collect()
};
Some(ToolUsage {
declared: authorized_tools.to_vec(),
actual,
unauthorized,
})
}
fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
if declared_set.contains(actual_name) {
return true;
}
for (canonical, aliases) in TOOL_ALIASES {
if *canonical == actual_name || aliases.contains(&actual_name) {
for alias in *aliases {
if declared_set.contains(*alias) {
return true;
}
}
return false;
}
}
false
}
fn event_type_label(et: &super::event::EventType) -> String {
use super::event::EventType::*;
match et {
SessionStarted => "session.started",
SessionClosed { .. } => "session.closed",
AgentStarted { .. } => "agent.started",
AgentSpawned { .. } => "agent.spawned",
AgentHandoff { .. } => "agent.handoff",
AgentCollaborated { .. } => "agent.collaborated",
AgentReturned { .. } => "agent.returned",
AgentCompleted { .. } => "agent.completed",
AgentFailed { .. } => "agent.failed",
AgentCalledTool { .. } => "agent.called_tool",
AgentReadFile { .. } => "agent.read_file",
AgentWroteFile { .. } => "agent.wrote_file",
AgentOpenedPort { .. } => "agent.opened_port",
AgentConnectedNetwork { .. } => "agent.connected_network",
AgentStartedProcess { .. } => "agent.started_process",
AgentCompletedProcess { .. } => "agent.completed_process",
AgentDecision { .. } => "agent.decision",
}.into()
}
fn event_summary(et: &super::event::EventType) -> Option<String> {
use super::event::EventType::*;
match et {
SessionStarted => Some("Session started".into()),
SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
AgentSpawned { reason, .. } => reason.clone(),
AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
}
AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
AgentCompletedProcess { process_name, exit_code, .. } => {
Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
}
AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
AgentDecision { model, summary, provider, .. } => {
let mut parts = Vec::new();
if let Some(s) = summary { parts.push(s.clone()); }
if let Some(m) = model { parts.push(format!("model: {m}")); }
if let Some(p) = provider { parts.push(format!("via {p}")); }
if parts.is_empty() { Some("LLM decision".into()) } else { Some(parts.join(" | ")) }
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::event::*;
fn make_manifest() -> SessionManifest {
SessionManifest::new(
"ssn_001".into(),
"agent://test".into(),
"2026-04-05T08:00:00Z".into(),
1743843600000,
)
}
fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
SessionEvent {
session_id: "ssn_001".into(),
event_id: format!("evt_{:016x}", seq),
timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
sequence_no: seq,
trace_id: "trace_1".into(),
span_id: format!("span_{seq}"),
parent_span_id: None,
agent_id: format!("agent://{inst}"),
agent_instance_id: inst.into(),
agent_name: inst.into(),
agent_role: None,
host_id: "host_1".into(),
tool_runtime_id: None,
event_type: et,
artifact_ref: None,
meta: None,
}
}
fn make_events() -> Vec<SessionEvent> {
vec![
mk(0, "root", EventType::SessionStarted),
mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None, operation: None, additions: None, deletions: None }),
mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
]
}
#[test]
fn compose_receipt() {
let manifest = make_manifest();
let events = make_events();
let artifacts = vec![
ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
];
let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
assert_eq!(receipt.type_, RECEIPT_TYPE);
assert_eq!(receipt.session.id, "ssn_001");
assert_eq!(receipt.timeline.len(), 7);
assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
assert_eq!(receipt.merkle.leaf_count, 2);
assert!(receipt.merkle.root.is_some());
}
#[test]
fn new_receipts_carry_schema_version() {
let manifest = make_manifest();
let events = make_events();
let artifacts = vec![
ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
];
let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
assert_eq!(receipt.schema_version.as_deref(), Some(RECEIPT_SCHEMA_VERSION));
let json = String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
assert!(json.contains(r#""schema_version":"1""#), "missing schema_version: {json}");
}
#[test]
fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
let manifest = make_manifest();
let events = make_events();
let artifacts = vec![
ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
];
let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
receipt.schema_version = None;
let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
let original_str = std::str::from_utf8(&original).unwrap();
assert!(!original_str.contains("schema_version"),
"schema_version must be skipped when None");
let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
assert!(parsed.schema_version.is_none(), "legacy receipts must parse with schema_version=None");
let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
assert_eq!(original, reserialized,
"legacy receipt must round-trip byte-identical so package determinism check passes");
}
#[test]
fn canonical_json_is_deterministic() {
let manifest = make_manifest();
let events = make_events();
let artifacts = vec![
ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
];
let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
assert_eq!(j1, j2);
let d1 = ReceiptComposer::digest(&r1).unwrap();
let d2 = ReceiptComposer::digest(&r2).unwrap();
assert_eq!(d1, d2);
}
fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
let mut m = make_manifest();
m.authorized_tools = tools.into_iter().map(String::from).collect();
m
}
#[test]
fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); let events = vec![
mk(0, "root", EventType::SessionStarted),
mk(1, "agent", EventType::AgentCompletedProcess {
process_name: "rm -rf /".into(),
exit_code: Some(0),
duration_ms: Some(50),
command: Some("rm -rf /".into()),
}),
mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
];
let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
let tu = receipt.tool_usage.expect("tool_usage must be populated");
assert!(
tu.unauthorized.iter().any(|t| t == "bash"),
"bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
tu.unauthorized, tu.actual,
);
}
#[test]
fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
let manifest = manifest_with_authorized(vec!["read_file", "bash"]); let events = vec![
mk(0, "root", EventType::SessionStarted),
mk(1, "agent", EventType::AgentWroteFile {
file_path: "src/secret.rs".into(),
digest: None, operation: Some("modified".into()),
additions: Some(10), deletions: Some(0),
}),
mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
];
let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
let tu = receipt.tool_usage.expect("tool_usage must be populated");
assert!(
tu.unauthorized.iter().any(|t| t == "write_file"),
"write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
tu.unauthorized, tu.actual,
);
}
#[test]
fn cert_includes_read_write_bash_passes_clean_when_all_used() {
let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
let events = vec![
mk(0, "root", EventType::SessionStarted),
mk(1, "agent", EventType::AgentReadFile { file_path: "package.json".into(), digest: None }),
mk(2, "agent", EventType::AgentWroteFile {
file_path: "src/lib.rs".into(),
digest: None, operation: Some("modified".into()),
additions: Some(5), deletions: Some(2),
}),
mk(3, "agent", EventType::AgentCompletedProcess {
process_name: "bun test".into(),
exit_code: Some(0), duration_ms: Some(2000),
command: Some("bun test".into()),
}),
mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(5000) }),
];
let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
let tu = receipt.tool_usage.expect("tool_usage must be populated");
assert!(
tu.unauthorized.is_empty(),
"all tools declared in cert should pass clean; got unauthorized={:?}",
tu.unauthorized,
);
let actual_names: std::collections::BTreeSet<String> =
tu.actual.iter().map(|e| e.tool_name.clone()).collect();
assert!(actual_names.contains("read_file"));
assert!(actual_names.contains("write_file"));
assert!(actual_names.contains("bash"));
}
#[test]
fn webfetch_unauthorized_flagged_when_cert_omits_it() {
let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); let events = vec![
mk(0, "root", EventType::SessionStarted),
mk(1, "agent", EventType::AgentConnectedNetwork {
destination: "evil.example.com".into(),
port: Some(443),
}),
mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
];
let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
let tu = receipt.tool_usage.expect("tool_usage must be populated");
assert!(
tu.unauthorized.iter().any(|t| t == "web_fetch"),
"web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
tu.unauthorized,
);
}
fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
let mut e = mk(99, "agent", event_type);
e.meta = Some(serde_json::json!({"source": source}));
e
}
#[test]
fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
let events = vec![
mk(0, "root", EventType::SessionStarted),
mk(1, "agent", EventType::AgentReadFile { file_path: "x".into(), digest: None }),
mk(2, "agent", EventType::AgentWroteFile {
file_path: "y".into(),
digest: None, operation: None, additions: None, deletions: None,
}),
mk(3, "agent", EventType::AgentCompletedProcess {
process_name: "z".into(),
exit_code: Some(0), duration_ms: Some(1), command: None,
}),
mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
];
let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
assert!(
tu.unauthorized.is_empty(),
"TitleCase declarations must authorize canonical snake_case actuals via aliases; \
got unauthorized={:?}",
tu.unauthorized,
);
}
#[test]
fn edit_alias_authorizes_specialized_wrote_file() {
let manifest = manifest_with_authorized(vec!["Edit"]);
let events = vec![
mk(0, "root", EventType::SessionStarted),
mk(1, "agent", EventType::AgentWroteFile {
file_path: "x".into(),
digest: None, operation: None, additions: None, deletions: None,
}),
mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
];
let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
assert!(tu.unauthorized.is_empty(), "Edit alias must authorize write_file");
}
#[test]
fn git_reconcile_writes_dont_count_toward_tool_usage() {
let manifest = manifest_with_authorized(vec!["read_file"]);
let events = vec![
mk(0, "root", EventType::SessionStarted),
evt_with_source(
EventType::AgentWroteFile {
file_path: "CHANGELOG.md".into(),
digest: None, operation: Some("modified".into()),
additions: Some(7), deletions: Some(2),
},
"git-reconcile",
),
mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
];
let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
assert!(
!tu.unauthorized.iter().any(|t| t == "write_file"),
"git-reconcile entries must NOT count toward tool_usage; \
got unauthorized={:?}, actual={:?}",
tu.unauthorized, tu.actual,
);
let actual_names: std::collections::BTreeSet<String> =
tu.actual.iter().map(|e| e.tool_name.clone()).collect();
assert!(!actual_names.contains("write_file"),
"actual must not include backstop-only writes");
}
#[test]
fn hook_emitted_writes_still_count_toward_tool_usage() {
let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
mk(0, "root", EventType::SessionStarted),
evt_with_source(
EventType::AgentWroteFile {
file_path: "src/x.rs".into(),
digest: None, operation: None, additions: None, deletions: None,
},
"hook",
),
mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
];
let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
assert!(
tu.unauthorized.iter().any(|t| t == "write_file"),
"hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
tu.unauthorized,
);
}
#[test]
fn legacy_untagged_writes_count_for_back_compat() {
let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
mk(0, "root", EventType::SessionStarted),
mk(1, "agent", EventType::AgentWroteFile {
file_path: "x".into(),
digest: None, operation: None, additions: None, deletions: None,
}),
mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
];
let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
assert!(
tu.unauthorized.iter().any(|t| t == "write_file"),
"legacy untagged writes must count for back-compat",
);
}
}