1use serde::{Deserialize, Serialize};
8use sha2::{Sha256, Digest};
9
10use crate::merkle::{MerkleTree, InclusionProof};
11
12use super::event::SessionEvent;
13use super::graph::AgentGraph;
14use super::manifest::{
15 HostInfo, LifecycleMode, Participants, SessionManifest, SessionStatus, ToolInfo,
16};
17use super::render::RenderConfig;
18use super::side_effects::SideEffects;
19
20pub const RECEIPT_TYPE: &str = "treeship/session-receipt/v1";
22
23pub const RECEIPT_SCHEMA_VERSION: &str = "1";
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SessionReceipt {
32 #[serde(rename = "type")]
34 pub type_: String,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub schema_version: Option<String>,
40
41 pub session: SessionSection,
42 pub participants: Participants,
43 pub hosts: Vec<HostInfo>,
44 pub tools: Vec<ToolInfo>,
45 pub agent_graph: AgentGraph,
46 pub timeline: Vec<TimelineEntry>,
47 pub side_effects: SideEffects,
48 pub artifacts: Vec<ArtifactEntry>,
49 pub proofs: ProofsSection,
50 pub merkle: MerkleSection,
51 pub render: RenderConfig,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub tool_usage: Option<ToolUsage>,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct ToolUsage {
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub declared: Vec<String>,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub actual: Vec<ToolUsageEntry>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub unauthorized: Vec<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ToolUsageEntry {
74 pub tool_name: String,
75 pub count: u32,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SessionSection {
81 pub id: String,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub name: Option<String>,
84 pub mode: LifecycleMode,
85 pub started_at: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub ended_at: Option<String>,
88 pub status: SessionStatus,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub duration_ms: Option<u64>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub ship_id: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub narrative: Option<Narrative>,
101 #[serde(default)]
103 pub total_tokens_in: u64,
104 #[serde(default)]
106 pub total_tokens_out: u64,
107}
108
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct Narrative {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub headline: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub summary: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub review: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct TimelineEntry {
127 pub sequence_no: u64,
128 pub timestamp: String,
129 pub event_id: String,
130 pub event_type: String,
131 pub agent_instance_id: String,
132 pub agent_name: String,
133 pub host_id: String,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub summary: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ArtifactEntry {
141 pub artifact_id: String,
142 pub payload_type: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub digest: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub signed_at: Option<String>,
147}
148
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct ProofsSection {
152 #[serde(default)]
153 pub signature_count: u32,
154 #[serde(default)]
155 pub signatures_valid: bool,
156 #[serde(default)]
157 pub merkle_root_valid: bool,
158 #[serde(default)]
159 pub inclusion_proofs_count: u32,
160 #[serde(default)]
161 pub zk_proofs_present: bool,
162 #[serde(default, skip_serializing_if = "is_zero_u32")]
171 pub event_log_skipped: u32,
172}
173
174fn is_zero_u32(n: &u32) -> bool { *n == 0 }
175
176#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178pub struct MerkleSection {
179 pub leaf_count: usize,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub root: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub checkpoint_id: Option<String>,
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub inclusion_proofs: Vec<InclusionProofEntry>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct InclusionProofEntry {
191 pub artifact_id: String,
192 pub leaf_index: usize,
193 pub proof: InclusionProof,
194}
195
196pub struct ReceiptComposer;
200
201impl ReceiptComposer {
202 pub fn compose(
204 manifest: &SessionManifest,
205 events: &[SessionEvent],
206 artifact_entries: Vec<ArtifactEntry>,
207 ) -> SessionReceipt {
208 let agent_graph = AgentGraph::from_events(events);
210
211 let side_effects = SideEffects::from_events(events);
213
214 let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
216 TimelineEntry {
217 sequence_no: e.sequence_no,
218 timestamp: e.timestamp.clone(),
219 event_id: e.event_id.clone(),
220 event_type: event_type_label(&e.event_type),
221 agent_instance_id: e.agent_instance_id.clone(),
222 agent_name: e.agent_name.clone(),
223 host_id: e.host_id.clone(),
224 summary: event_summary(&e.event_type),
225 }
226 }).collect();
227
228 timeline.sort_by(|a, b| {
230 a.timestamp.cmp(&b.timestamp)
231 .then(a.sequence_no.cmp(&b.sequence_no))
232 .then(a.event_id.cmp(&b.event_id))
233 });
234
235 let participants = compute_participants(&agent_graph, manifest);
237
238 let hosts = compute_hosts(events, &manifest.hosts);
240 let tools = compute_tools(events, &manifest.tools);
241
242 let duration_ms = events.iter().find_map(|e| {
244 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
245 *duration_ms
246 } else {
247 None
248 }
249 });
250
251 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
253
254 let proofs = ProofsSection {
258 signature_count: artifact_entries.len() as u32,
259 signatures_valid: true, merkle_root_valid: merkle_tree.is_some(),
261 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
262 zk_proofs_present: false,
263 event_log_skipped: 0, };
265
266 let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
269 let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
270
271 let session = SessionSection {
273 id: manifest.session_id.clone(),
274 name: manifest.name.clone(),
275 mode: manifest.mode.clone(),
276 started_at: manifest.started_at.clone(),
277 ended_at: manifest.closed_at.clone(),
278 status: manifest.status.clone(),
279 duration_ms,
280 ship_id: parse_ship_id_from_actor(&manifest.actor),
281 narrative: manifest.summary.as_ref().map(|s| Narrative {
282 headline: manifest.name.clone(),
283 summary: Some(s.clone()),
284 review: None,
285 }),
286 total_tokens_in,
287 total_tokens_out,
288 };
289
290 let render = RenderConfig {
292 title: manifest.name.clone(),
293 theme: None,
294 sections: RenderConfig::default_sections(),
295 generate_preview: true,
296 };
297
298 let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
300
301 SessionReceipt {
302 type_: RECEIPT_TYPE.into(),
303 schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
304 session,
305 participants,
306 hosts,
307 tools,
308 agent_graph,
309 timeline,
310 side_effects,
311 artifacts: artifact_entries,
312 proofs,
313 merkle: merkle_section,
314 render,
315 tool_usage,
316 }
317 }
318
319 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
324 serde_json::to_vec(receipt)
325 }
326
327 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
329 let bytes = Self::to_canonical_json(receipt)?;
330 let hash = Sha256::digest(&bytes);
331 Ok(format!("sha256:{}", hex::encode(hash)))
332 }
333}
334
335fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
338 use std::collections::BTreeSet;
339
340 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
341 let total_agents = graph.nodes.len() as u32;
343 let spawned_subagents = graph.spawn_count();
344 let handoffs = graph.handoff_count();
345 let max_depth = graph.max_depth();
346 let host_ids = graph.host_ids();
347
348 for tool in &manifest.tools {
350 if let Some(ref rt) = tool.tool_runtime_id {
351 tool_runtimes.insert(rt.clone());
352 }
353 }
354
355 let root = graph.nodes.iter()
357 .filter(|n| n.depth == 0)
358 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
359 .map(|n| n.agent_instance_id.clone());
360
361 let final_output = graph.nodes.iter()
363 .filter(|n| n.completed_at.is_some())
364 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
365 .map(|n| n.agent_instance_id.clone());
366
367 Participants {
368 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
369 final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
370 total_agents,
371 spawned_subagents,
372 handoffs,
373 max_depth,
374 hosts: host_ids.len() as u32,
375 tool_runtimes: tool_runtimes.len() as u32,
376 }
377}
378
379fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
380 use std::collections::BTreeMap;
381
382 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
383
384 for h in manifest_hosts {
386 hosts.insert(h.host_id.clone(), h.clone());
387 }
388
389 for e in events {
391 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
392 host_id: e.host_id.clone(),
393 hostname: None,
394 os: None,
395 arch: None,
396 });
397 }
398
399 hosts.into_values().collect()
400}
401
402fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
403 use std::collections::BTreeMap;
404
405 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
406
407 for t in manifest_tools {
409 tools.insert(t.tool_id.clone(), t.clone());
410 }
411
412 for e in events {
414 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
415 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
416 tool_id: tool_name.clone(),
417 tool_name: tool_name.clone(),
418 tool_runtime_id: e.tool_runtime_id.clone(),
419 invocation_count: 0,
420 });
421 entry.invocation_count += 1;
422 }
423 }
424
425 tools.into_values().collect()
426}
427
428fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
429 if artifacts.is_empty() {
430 return (MerkleSection::default(), None);
431 }
432
433 let mut tree = MerkleTree::new();
434 for art in artifacts {
435 tree.append(&art.artifact_id);
436 }
437
438 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
439
440 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
442 .filter_map(|(i, art)| {
443 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
444 artifact_id: art.artifact_id.clone(),
445 leaf_index: i,
446 proof,
447 })
448 })
449 .collect();
450
451 let section = MerkleSection {
452 leaf_count: artifacts.len(),
453 root,
454 checkpoint_id: None,
455 inclusion_proofs,
456 };
457
458 (section, Some(tree))
459}
460
461pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
464 let rest = actor.strip_prefix("ship://")?;
465 let id = rest.split('/').next().unwrap_or(rest);
467 if id.is_empty() { None } else { Some(id.to_string()) }
468}
469
470const TOOL_ALIASES: &[(&str, &[&str])] = &[
520 ("read_file", &["read_file", "Read"]),
522 ("write_file", &["write_file", "Write", "Edit", "MultiEdit", "NotebookEdit", "edit_file"]),
523 ("bash", &["bash", "Bash", "shell"]),
524 ("web_fetch", &["web_fetch", "WebFetch", "webfetch"]),
525];
526
527fn source_attributes_a_tool(source: Option<&str>) -> bool {
552 matches!(
553 source,
554 None
555 | Some("hook")
556 | Some("mcp")
557 | Some("shell-wrap")
558 | Some("session-event-cli"),
559 )
560}
561
562fn count_attributed<'a, F>(
565 items: usize,
566 source_at: F,
567 canonical: &str,
568 counts: &mut std::collections::BTreeMap<String, u32>,
569)
570where
571 F: Fn(usize) -> Option<&'a str>,
572{
573 let n: u32 = (0..items)
574 .filter(|i| source_attributes_a_tool(source_at(*i)))
575 .count() as u32;
576 if n > 0 {
577 *counts.entry(canonical.to_string()).or_insert(0) += n;
578 }
579}
580
581fn derive_tool_usage(
582 side_effects: &SideEffects,
583 authorized_tools: &[String],
584) -> Option<ToolUsage> {
585 use std::collections::BTreeMap;
586
587 let total_specialized = side_effects.files_read.len()
588 + side_effects.files_written.len()
589 + side_effects.processes.len()
590 + side_effects.network_connections.len();
591
592 if side_effects.tool_invocations.is_empty()
593 && total_specialized == 0
594 && authorized_tools.is_empty()
595 {
596 return None;
597 }
598
599 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
600
601 for inv in &side_effects.tool_invocations {
608 *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
609 }
610
611 let fr = &side_effects.files_read;
616 count_attributed(
617 fr.len(),
618 |i| fr[i].source.as_deref(),
619 "read_file",
620 &mut counts,
621 );
622 let fw = &side_effects.files_written;
623 count_attributed(
624 fw.len(),
625 |i| fw[i].source.as_deref(),
626 "write_file",
627 &mut counts,
628 );
629 let pr = &side_effects.processes;
630 count_attributed(
631 pr.len(),
632 |i| pr[i].source.as_deref(),
633 "bash",
634 &mut counts,
635 );
636 if !side_effects.network_connections.is_empty() {
640 *counts.entry("web_fetch".to_string()).or_insert(0) +=
641 side_effects.network_connections.len() as u32;
642 }
643
644 let actual: Vec<ToolUsageEntry> = counts.iter()
645 .map(|(name, &count)| ToolUsageEntry { tool_name: name.clone(), count })
646 .collect();
647
648 let unauthorized = if authorized_tools.is_empty() {
654 Vec::new()
655 } else {
656 let declared_set: std::collections::BTreeSet<&str> = authorized_tools.iter()
657 .map(|s| s.as_str())
658 .collect();
659 counts.keys()
660 .filter(|actual_name| !is_authorized(actual_name, &declared_set))
661 .cloned()
662 .collect()
663 };
664
665 Some(ToolUsage {
666 declared: authorized_tools.to_vec(),
667 actual,
668 unauthorized,
669 })
670}
671
672fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
677 if declared_set.contains(actual_name) {
679 return true;
680 }
681 for (canonical, aliases) in TOOL_ALIASES {
684 if *canonical == actual_name || aliases.contains(&actual_name) {
685 for alias in *aliases {
686 if declared_set.contains(*alias) {
687 return true;
688 }
689 }
690 return false;
691 }
692 }
693 false
694}
695
696fn event_type_label(et: &super::event::EventType) -> String {
697 use super::event::EventType::*;
698 match et {
699 SessionStarted => "session.started",
700 SessionClosed { .. } => "session.closed",
701 AgentStarted { .. } => "agent.started",
702 AgentSpawned { .. } => "agent.spawned",
703 AgentHandoff { .. } => "agent.handoff",
704 AgentCollaborated { .. } => "agent.collaborated",
705 AgentReturned { .. } => "agent.returned",
706 AgentCompleted { .. } => "agent.completed",
707 AgentFailed { .. } => "agent.failed",
708 AgentCalledTool { .. } => "agent.called_tool",
709 AgentReadFile { .. } => "agent.read_file",
710 AgentWroteFile { .. } => "agent.wrote_file",
711 AgentOpenedPort { .. } => "agent.opened_port",
712 AgentConnectedNetwork { .. } => "agent.connected_network",
713 AgentStartedProcess { .. } => "agent.started_process",
714 AgentCompletedProcess { .. } => "agent.completed_process",
715 AgentDecision { .. } => "agent.decision",
716 }.into()
717}
718
719fn event_summary(et: &super::event::EventType) -> Option<String> {
721 use super::event::EventType::*;
722 match et {
723 SessionStarted => Some("Session started".into()),
724 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
725 AgentSpawned { reason, .. } => reason.clone(),
726 AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
727 Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
728 }
729 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
730 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
731 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
732 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
733 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
734 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
735 AgentCompletedProcess { process_name, exit_code, .. } => {
736 Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
737 }
738 AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
739 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
740 AgentDecision { model, summary, provider, .. } => {
741 let mut parts = Vec::new();
742 if let Some(s) = summary { parts.push(s.clone()); }
743 if let Some(m) = model { parts.push(format!("model: {m}")); }
744 if let Some(p) = provider { parts.push(format!("via {p}")); }
745 if parts.is_empty() { Some("LLM decision".into()) } else { Some(parts.join(" | ")) }
746 }
747 _ => None,
748 }
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use crate::session::event::*;
755
756 fn make_manifest() -> SessionManifest {
757 SessionManifest::new(
758 "ssn_001".into(),
759 "agent://test".into(),
760 "2026-04-05T08:00:00Z".into(),
761 1743843600000,
762 )
763 }
764
765 fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
768 SessionEvent {
769 session_id: "ssn_001".into(),
770 event_id: format!("evt_{:016x}", seq),
771 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
772 sequence_no: seq,
773 trace_id: "trace_1".into(),
774 span_id: format!("span_{seq}"),
775 parent_span_id: None,
776 agent_id: format!("agent://{inst}"),
777 agent_instance_id: inst.into(),
778 agent_name: inst.into(),
779 agent_role: None,
780 host_id: "host_1".into(),
781 tool_runtime_id: None,
782 event_type: et,
783 artifact_ref: None,
784 meta: None,
785 }
786 }
787
788 fn make_events() -> Vec<SessionEvent> {
789 vec![
790 mk(0, "root", EventType::SessionStarted),
791 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
792 mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
793 mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
794 mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None, operation: None, additions: None, deletions: None }),
795 mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
796 mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
797 ]
798 }
799
800 #[test]
801 fn compose_receipt() {
802 let manifest = make_manifest();
803 let events = make_events();
804 let artifacts = vec![
805 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
806 ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
807 ];
808
809 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
810
811 assert_eq!(receipt.type_, RECEIPT_TYPE);
812 assert_eq!(receipt.session.id, "ssn_001");
813 assert_eq!(receipt.timeline.len(), 7);
814 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
816 assert_eq!(receipt.merkle.leaf_count, 2);
817 assert!(receipt.merkle.root.is_some());
818 }
819
820 #[test]
821 fn new_receipts_carry_schema_version() {
822 let manifest = make_manifest();
823 let events = make_events();
824 let artifacts = vec![
825 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
826 ];
827 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
828 assert_eq!(receipt.schema_version.as_deref(), Some(RECEIPT_SCHEMA_VERSION));
829 let json = String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
831 assert!(json.contains(r#""schema_version":"1""#), "missing schema_version: {json}");
832 }
833
834 #[test]
835 fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
836 let manifest = make_manifest();
841 let events = make_events();
842 let artifacts = vec![
843 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
844 ];
845 let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
846 receipt.schema_version = None; let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
849 let original_str = std::str::from_utf8(&original).unwrap();
851 assert!(!original_str.contains("schema_version"),
852 "schema_version must be skipped when None");
853
854 let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
855 assert!(parsed.schema_version.is_none(), "legacy receipts must parse with schema_version=None");
856
857 let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
858 assert_eq!(original, reserialized,
859 "legacy receipt must round-trip byte-identical so package determinism check passes");
860 }
861
862 #[test]
863 fn canonical_json_is_deterministic() {
864 let manifest = make_manifest();
865 let events = make_events();
866 let artifacts = vec![
867 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
868 ];
869
870 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
871 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
872
873 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
874 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
875 assert_eq!(j1, j2);
876
877 let d1 = ReceiptComposer::digest(&r1).unwrap();
878 let d2 = ReceiptComposer::digest(&r2).unwrap();
879 assert_eq!(d1, d2);
880 }
881
882 fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
892 let mut m = make_manifest();
893 m.authorized_tools = tools.into_iter().map(String::from).collect();
894 m
895 }
896
897 #[test]
898 fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
899 let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); let events = vec![
904 mk(0, "root", EventType::SessionStarted),
905 mk(1, "agent", EventType::AgentCompletedProcess {
906 process_name: "rm -rf /".into(),
907 exit_code: Some(0),
908 duration_ms: Some(50),
909 command: Some("rm -rf /".into()),
910 }),
911 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
912 ];
913 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
914 let tu = receipt.tool_usage.expect("tool_usage must be populated");
915 assert!(
916 tu.unauthorized.iter().any(|t| t == "bash"),
917 "bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
918 tu.unauthorized, tu.actual,
919 );
920 }
921
922 #[test]
923 fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
924 let manifest = manifest_with_authorized(vec!["read_file", "bash"]); let events = vec![
926 mk(0, "root", EventType::SessionStarted),
927 mk(1, "agent", EventType::AgentWroteFile {
928 file_path: "src/secret.rs".into(),
929 digest: None, operation: Some("modified".into()),
930 additions: Some(10), deletions: Some(0),
931 }),
932 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
933 ];
934 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
935 let tu = receipt.tool_usage.expect("tool_usage must be populated");
936 assert!(
937 tu.unauthorized.iter().any(|t| t == "write_file"),
938 "write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
939 tu.unauthorized, tu.actual,
940 );
941 }
942
943 #[test]
944 fn cert_includes_read_write_bash_passes_clean_when_all_used() {
945 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
946 let events = vec![
947 mk(0, "root", EventType::SessionStarted),
948 mk(1, "agent", EventType::AgentReadFile { file_path: "package.json".into(), digest: None }),
949 mk(2, "agent", EventType::AgentWroteFile {
950 file_path: "src/lib.rs".into(),
951 digest: None, operation: Some("modified".into()),
952 additions: Some(5), deletions: Some(2),
953 }),
954 mk(3, "agent", EventType::AgentCompletedProcess {
955 process_name: "bun test".into(),
956 exit_code: Some(0), duration_ms: Some(2000),
957 command: Some("bun test".into()),
958 }),
959 mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(5000) }),
960 ];
961 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
962 let tu = receipt.tool_usage.expect("tool_usage must be populated");
963 assert!(
964 tu.unauthorized.is_empty(),
965 "all tools declared in cert should pass clean; got unauthorized={:?}",
966 tu.unauthorized,
967 );
968 let actual_names: std::collections::BTreeSet<String> =
972 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
973 assert!(actual_names.contains("read_file"));
974 assert!(actual_names.contains("write_file"));
975 assert!(actual_names.contains("bash"));
976 }
977
978 #[test]
979 fn webfetch_unauthorized_flagged_when_cert_omits_it() {
980 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); let events = vec![
982 mk(0, "root", EventType::SessionStarted),
983 mk(1, "agent", EventType::AgentConnectedNetwork {
984 destination: "evil.example.com".into(),
985 port: Some(443),
986 }),
987 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
988 ];
989 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
990 let tu = receipt.tool_usage.expect("tool_usage must be populated");
991 assert!(
992 tu.unauthorized.iter().any(|t| t == "web_fetch"),
993 "web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
994 tu.unauthorized,
995 );
996 }
997
998 fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
1001 let mut e = mk(99, "agent", event_type);
1002 e.meta = Some(serde_json::json!({"source": source}));
1003 e
1004 }
1005
1006 #[test]
1007 fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
1008 let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
1011 let events = vec![
1012 mk(0, "root", EventType::SessionStarted),
1013 mk(1, "agent", EventType::AgentReadFile { file_path: "x".into(), digest: None }),
1014 mk(2, "agent", EventType::AgentWroteFile {
1015 file_path: "y".into(),
1016 digest: None, operation: None, additions: None, deletions: None,
1017 }),
1018 mk(3, "agent", EventType::AgentCompletedProcess {
1019 process_name: "z".into(),
1020 exit_code: Some(0), duration_ms: Some(1), command: None,
1021 }),
1022 mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1023 ];
1024 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1025 assert!(
1026 tu.unauthorized.is_empty(),
1027 "TitleCase declarations must authorize canonical snake_case actuals via aliases; \
1028 got unauthorized={:?}",
1029 tu.unauthorized,
1030 );
1031 }
1032
1033 #[test]
1034 fn edit_alias_authorizes_specialized_wrote_file() {
1035 let manifest = manifest_with_authorized(vec!["Edit"]);
1040 let events = vec![
1041 mk(0, "root", EventType::SessionStarted),
1042 mk(1, "agent", EventType::AgentWroteFile {
1043 file_path: "x".into(),
1044 digest: None, operation: None, additions: None, deletions: None,
1045 }),
1046 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1047 ];
1048 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1049 assert!(tu.unauthorized.is_empty(), "Edit alias must authorize write_file");
1050 }
1051
1052 #[test]
1053 fn git_reconcile_writes_dont_count_toward_tool_usage() {
1054 let manifest = manifest_with_authorized(vec!["read_file"]);
1058 let events = vec![
1059 mk(0, "root", EventType::SessionStarted),
1060 evt_with_source(
1061 EventType::AgentWroteFile {
1062 file_path: "CHANGELOG.md".into(),
1063 digest: None, operation: Some("modified".into()),
1064 additions: Some(7), deletions: Some(2),
1065 },
1066 "git-reconcile",
1067 ),
1068 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1069 ];
1070 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1071 assert!(
1072 !tu.unauthorized.iter().any(|t| t == "write_file"),
1073 "git-reconcile entries must NOT count toward tool_usage; \
1074 got unauthorized={:?}, actual={:?}",
1075 tu.unauthorized, tu.actual,
1076 );
1077 let actual_names: std::collections::BTreeSet<String> =
1078 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1079 assert!(!actual_names.contains("write_file"),
1080 "actual must not include backstop-only writes");
1081 }
1082
1083 #[test]
1092 fn hook_emitted_writes_still_count_toward_tool_usage() {
1093 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1096 mk(0, "root", EventType::SessionStarted),
1097 evt_with_source(
1098 EventType::AgentWroteFile {
1099 file_path: "src/x.rs".into(),
1100 digest: None, operation: None, additions: None, deletions: None,
1101 },
1102 "hook",
1103 ),
1104 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1105 ];
1106 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1107 assert!(
1108 tu.unauthorized.iter().any(|t| t == "write_file"),
1109 "hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
1110 tu.unauthorized,
1111 );
1112 }
1113
1114 #[test]
1115 fn legacy_untagged_writes_count_for_back_compat() {
1116 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1120 mk(0, "root", EventType::SessionStarted),
1121 mk(1, "agent", EventType::AgentWroteFile {
1122 file_path: "x".into(),
1123 digest: None, operation: None, additions: None, deletions: None,
1124 }),
1125 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1126 ];
1127 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1128 assert!(
1129 tu.unauthorized.iter().any(|t| t == "write_file"),
1130 "legacy untagged writes must count for back-compat",
1131 );
1132 }
1133}