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 #[serde(default, skip_serializing_if = "is_zero_u32")]
173 pub reconcile_untracked_truncated: u32,
174 #[serde(default, skip_serializing_if = "is_zero_u32")]
175 pub reconcile_untracked_cap: u32,
176}
177
178fn is_zero_u32(n: &u32) -> bool { *n == 0 }
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct MerkleSection {
183 pub leaf_count: usize,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub root: Option<String>,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub checkpoint_id: Option<String>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub inclusion_proofs: Vec<InclusionProofEntry>,
190 #[serde(default = "crate::merkle::tree::default_merkle_version_v1")]
195 pub merkle_version: u8,
196}
197
198impl Default for MerkleSection {
199 fn default() -> Self {
200 Self {
204 leaf_count: 0,
205 root: None,
206 checkpoint_id: None,
207 inclusion_proofs: Vec::new(),
208 merkle_version: crate::merkle::tree::MERKLE_VERSION_V2,
209 }
210 }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct InclusionProofEntry {
216 pub artifact_id: String,
217 pub leaf_index: usize,
218 pub proof: InclusionProof,
219}
220
221pub struct ReceiptComposer;
225
226impl ReceiptComposer {
227 pub fn compose(
229 manifest: &SessionManifest,
230 events: &[SessionEvent],
231 artifact_entries: Vec<ArtifactEntry>,
232 ) -> SessionReceipt {
233 let agent_graph = AgentGraph::from_events(events);
235
236 let side_effects = SideEffects::from_events(events);
238
239 let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
241 TimelineEntry {
242 sequence_no: e.sequence_no,
243 timestamp: e.timestamp.clone(),
244 event_id: e.event_id.clone(),
245 event_type: event_type_label(&e.event_type),
246 agent_instance_id: e.agent_instance_id.clone(),
247 agent_name: e.agent_name.clone(),
248 host_id: e.host_id.clone(),
249 summary: event_summary(&e.event_type),
250 }
251 }).collect();
252
253 timeline.sort_by(|a, b| {
255 a.timestamp.cmp(&b.timestamp)
256 .then(a.sequence_no.cmp(&b.sequence_no))
257 .then(a.event_id.cmp(&b.event_id))
258 });
259
260 let participants = compute_participants(&agent_graph, manifest);
262
263 let hosts = compute_hosts(events, &manifest.hosts);
265 let tools = compute_tools(events, &manifest.tools);
266
267 let duration_ms = events.iter().find_map(|e| {
269 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
270 *duration_ms
271 } else {
272 None
273 }
274 });
275
276 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
278
279 let proofs = ProofsSection {
283 signature_count: artifact_entries.len() as u32,
284 signatures_valid: true, merkle_root_valid: merkle_tree.is_some(),
286 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
287 zk_proofs_present: false,
288 event_log_skipped: 0, reconcile_untracked_truncated: 0,
290 reconcile_untracked_cap: 0,
291 };
292
293 let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
296 let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
297
298 let session = SessionSection {
300 id: manifest.session_id.clone(),
301 name: manifest.name.clone(),
302 mode: manifest.mode.clone(),
303 started_at: manifest.started_at.clone(),
304 ended_at: manifest.closed_at.clone(),
305 status: manifest.status.clone(),
306 duration_ms,
307 ship_id: parse_ship_id_from_actor(&manifest.actor),
308 narrative: manifest.summary.as_ref().map(|s| Narrative {
309 headline: manifest.name.clone(),
310 summary: Some(s.clone()),
311 review: None,
312 }),
313 total_tokens_in,
314 total_tokens_out,
315 };
316
317 let render = RenderConfig {
319 title: manifest.name.clone(),
320 theme: None,
321 sections: RenderConfig::default_sections(),
322 generate_preview: true,
323 };
324
325 let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
327
328 SessionReceipt {
329 type_: RECEIPT_TYPE.into(),
330 schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
331 session,
332 participants,
333 hosts,
334 tools,
335 agent_graph,
336 timeline,
337 side_effects,
338 artifacts: artifact_entries,
339 proofs,
340 merkle: merkle_section,
341 render,
342 tool_usage,
343 }
344 }
345
346 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
351 serde_json::to_vec(receipt)
352 }
353
354 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
356 let bytes = Self::to_canonical_json(receipt)?;
357 let hash = Sha256::digest(&bytes);
358 Ok(format!("sha256:{}", hex::encode(hash)))
359 }
360}
361
362fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
365 use std::collections::BTreeSet;
366
367 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
368 let total_agents = graph.nodes.len() as u32;
370 let spawned_subagents = graph.spawn_count();
371 let handoffs = graph.handoff_count();
372 let max_depth = graph.max_depth();
373 let host_ids = graph.host_ids();
374
375 for tool in &manifest.tools {
377 if let Some(ref rt) = tool.tool_runtime_id {
378 tool_runtimes.insert(rt.clone());
379 }
380 }
381
382 let root = graph.nodes.iter()
384 .filter(|n| n.depth == 0)
385 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
386 .map(|n| n.agent_instance_id.clone());
387
388 let final_output = graph.nodes.iter()
390 .filter(|n| n.completed_at.is_some())
391 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
392 .map(|n| n.agent_instance_id.clone());
393
394 Participants {
395 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
396 final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
397 total_agents,
398 spawned_subagents,
399 handoffs,
400 max_depth,
401 hosts: host_ids.len() as u32,
402 tool_runtimes: tool_runtimes.len() as u32,
403 }
404}
405
406fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
407 use std::collections::BTreeMap;
408
409 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
410
411 for h in manifest_hosts {
413 hosts.insert(h.host_id.clone(), h.clone());
414 }
415
416 for e in events {
418 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
419 host_id: e.host_id.clone(),
420 hostname: None,
421 os: None,
422 arch: None,
423 });
424 }
425
426 hosts.into_values().collect()
427}
428
429fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
430 use std::collections::BTreeMap;
431
432 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
433
434 for t in manifest_tools {
436 tools.insert(t.tool_id.clone(), t.clone());
437 }
438
439 for e in events {
441 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
442 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
443 tool_id: tool_name.clone(),
444 tool_name: tool_name.clone(),
445 tool_runtime_id: e.tool_runtime_id.clone(),
446 invocation_count: 0,
447 });
448 entry.invocation_count += 1;
449 }
450 }
451
452 tools.into_values().collect()
453}
454
455fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
456 if artifacts.is_empty() {
457 return (MerkleSection::default(), None);
458 }
459
460 let mut tree = MerkleTree::new();
461 for art in artifacts {
462 tree.append(&art.artifact_id);
463 }
464
465 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
466
467 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
469 .filter_map(|(i, art)| {
470 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
471 artifact_id: art.artifact_id.clone(),
472 leaf_index: i,
473 proof,
474 })
475 })
476 .collect();
477
478 let section = MerkleSection {
479 leaf_count: artifacts.len(),
480 root,
481 checkpoint_id: None,
482 inclusion_proofs,
483 merkle_version: tree.version(),
484 };
485
486 (section, Some(tree))
487}
488
489pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
492 let rest = actor.strip_prefix("ship://")?;
493 let id = rest.split('/').next().unwrap_or(rest);
495 if id.is_empty() { None } else { Some(id.to_string()) }
496}
497
498const TOOL_ALIASES: &[(&str, &[&str])] = &[
548 ("read_file", &["read_file", "Read"]),
550 ("write_file", &["write_file", "Write", "Edit", "MultiEdit", "NotebookEdit", "edit_file"]),
551 ("bash", &["bash", "Bash", "shell"]),
552 ("web_fetch", &["web_fetch", "WebFetch", "webfetch"]),
553];
554
555fn source_attributes_a_tool(source: Option<&str>) -> bool {
580 matches!(
581 source,
582 None
583 | Some("hook")
584 | Some("mcp")
585 | Some("shell-wrap")
586 | Some("session-event-cli"),
587 )
588}
589
590fn count_attributed<'a, F>(
593 items: usize,
594 source_at: F,
595 canonical: &str,
596 counts: &mut std::collections::BTreeMap<String, u32>,
597)
598where
599 F: Fn(usize) -> Option<&'a str>,
600{
601 let n: u32 = (0..items)
602 .filter(|i| source_attributes_a_tool(source_at(*i)))
603 .count() as u32;
604 if n > 0 {
605 *counts.entry(canonical.to_string()).or_insert(0) += n;
606 }
607}
608
609fn derive_tool_usage(
610 side_effects: &SideEffects,
611 authorized_tools: &[String],
612) -> Option<ToolUsage> {
613 use std::collections::BTreeMap;
614
615 let total_specialized = side_effects.files_read.len()
616 + side_effects.files_written.len()
617 + side_effects.processes.len()
618 + side_effects.network_connections.len();
619
620 if side_effects.tool_invocations.is_empty()
621 && total_specialized == 0
622 && authorized_tools.is_empty()
623 {
624 return None;
625 }
626
627 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
628
629 for inv in &side_effects.tool_invocations {
636 *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
637 }
638
639 let fr = &side_effects.files_read;
644 count_attributed(
645 fr.len(),
646 |i| fr[i].source.as_deref(),
647 "read_file",
648 &mut counts,
649 );
650 let fw = &side_effects.files_written;
651 count_attributed(
652 fw.len(),
653 |i| fw[i].source.as_deref(),
654 "write_file",
655 &mut counts,
656 );
657 let pr = &side_effects.processes;
658 count_attributed(
659 pr.len(),
660 |i| pr[i].source.as_deref(),
661 "bash",
662 &mut counts,
663 );
664 if !side_effects.network_connections.is_empty() {
668 *counts.entry("web_fetch".to_string()).or_insert(0) +=
669 side_effects.network_connections.len() as u32;
670 }
671
672 let actual: Vec<ToolUsageEntry> = counts.iter()
673 .map(|(name, &count)| ToolUsageEntry { tool_name: name.clone(), count })
674 .collect();
675
676 let unauthorized = if authorized_tools.is_empty() {
682 Vec::new()
683 } else {
684 let declared_set: std::collections::BTreeSet<&str> = authorized_tools.iter()
685 .map(|s| s.as_str())
686 .collect();
687 counts.keys()
688 .filter(|actual_name| !is_authorized(actual_name, &declared_set))
689 .cloned()
690 .collect()
691 };
692
693 Some(ToolUsage {
694 declared: authorized_tools.to_vec(),
695 actual,
696 unauthorized,
697 })
698}
699
700fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
705 if declared_set.contains(actual_name) {
707 return true;
708 }
709 for (canonical, aliases) in TOOL_ALIASES {
712 if *canonical == actual_name || aliases.contains(&actual_name) {
713 for alias in *aliases {
714 if declared_set.contains(*alias) {
715 return true;
716 }
717 }
718 return false;
719 }
720 }
721 false
722}
723
724fn event_type_label(et: &super::event::EventType) -> String {
725 use super::event::EventType::*;
726 match et {
727 SessionStarted => "session.started",
728 SessionClosed { .. } => "session.closed",
729 AgentStarted { .. } => "agent.started",
730 AgentSpawned { .. } => "agent.spawned",
731 AgentHandoff { .. } => "agent.handoff",
732 AgentCollaborated { .. } => "agent.collaborated",
733 AgentReturned { .. } => "agent.returned",
734 AgentCompleted { .. } => "agent.completed",
735 AgentFailed { .. } => "agent.failed",
736 AgentCalledTool { .. } => "agent.called_tool",
737 AgentReadFile { .. } => "agent.read_file",
738 AgentWroteFile { .. } => "agent.wrote_file",
739 AgentOpenedPort { .. } => "agent.opened_port",
740 AgentConnectedNetwork { .. } => "agent.connected_network",
741 AgentStartedProcess { .. } => "agent.started_process",
742 AgentCompletedProcess { .. } => "agent.completed_process",
743 AgentDecision { .. } => "agent.decision",
744 }.into()
745}
746
747fn event_summary(et: &super::event::EventType) -> Option<String> {
749 use super::event::EventType::*;
750 match et {
751 SessionStarted => Some("Session started".into()),
752 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
753 AgentSpawned { reason, .. } => reason.clone(),
754 AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
755 Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
756 }
757 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
758 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
759 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
760 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
761 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
762 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
763 AgentCompletedProcess { process_name, exit_code, .. } => {
764 Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
765 }
766 AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
767 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
768 AgentDecision { model, summary, provider, .. } => {
769 let mut parts = Vec::new();
770 if let Some(s) = summary { parts.push(s.clone()); }
771 if let Some(m) = model { parts.push(format!("model: {m}")); }
772 if let Some(p) = provider { parts.push(format!("via {p}")); }
773 if parts.is_empty() { Some("LLM decision".into()) } else { Some(parts.join(" | ")) }
774 }
775 _ => None,
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use crate::session::event::*;
783
784 fn make_manifest() -> SessionManifest {
785 SessionManifest::new(
786 "ssn_001".into(),
787 "agent://test".into(),
788 "2026-04-05T08:00:00Z".into(),
789 1743843600000,
790 )
791 }
792
793 fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
796 SessionEvent {
797 session_id: "ssn_001".into(),
798 event_id: format!("evt_{:016x}", seq),
799 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
800 sequence_no: seq,
801 trace_id: "trace_1".into(),
802 span_id: format!("span_{seq}"),
803 parent_span_id: None,
804 agent_id: format!("agent://{inst}"),
805 agent_instance_id: inst.into(),
806 agent_name: inst.into(),
807 agent_role: None,
808 host_id: "host_1".into(),
809 tool_runtime_id: None,
810 event_type: et,
811 artifact_ref: None,
812 meta: None,
813 }
814 }
815
816 fn make_events() -> Vec<SessionEvent> {
817 vec![
818 mk(0, "root", EventType::SessionStarted),
819 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
820 mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
821 mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
822 mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None, operation: None, additions: None, deletions: None }),
823 mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
824 mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
825 ]
826 }
827
828 #[test]
829 fn compose_receipt() {
830 let manifest = make_manifest();
831 let events = make_events();
832 let artifacts = vec![
833 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
834 ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
835 ];
836
837 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
838
839 assert_eq!(receipt.type_, RECEIPT_TYPE);
840 assert_eq!(receipt.session.id, "ssn_001");
841 assert_eq!(receipt.timeline.len(), 7);
842 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
844 assert_eq!(receipt.merkle.leaf_count, 2);
845 assert!(receipt.merkle.root.is_some());
846 }
847
848 #[test]
849 fn new_receipts_carry_schema_version() {
850 let manifest = make_manifest();
851 let events = make_events();
852 let artifacts = vec![
853 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
854 ];
855 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
856 assert_eq!(receipt.schema_version.as_deref(), Some(RECEIPT_SCHEMA_VERSION));
857 let json = String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
859 assert!(json.contains(r#""schema_version":"1""#), "missing schema_version: {json}");
860 }
861
862 #[test]
863 fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
864 let manifest = make_manifest();
869 let events = make_events();
870 let artifacts = vec![
871 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
872 ];
873 let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
874 receipt.schema_version = None; let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
877 let original_str = std::str::from_utf8(&original).unwrap();
879 assert!(!original_str.contains("schema_version"),
880 "schema_version must be skipped when None");
881
882 let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
883 assert!(parsed.schema_version.is_none(), "legacy receipts must parse with schema_version=None");
884
885 let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
886 assert_eq!(original, reserialized,
887 "legacy receipt must round-trip byte-identical so package determinism check passes");
888 }
889
890 #[test]
891 fn canonical_json_is_deterministic() {
892 let manifest = make_manifest();
893 let events = make_events();
894 let artifacts = vec![
895 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
896 ];
897
898 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
899 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
900
901 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
902 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
903 assert_eq!(j1, j2);
904
905 let d1 = ReceiptComposer::digest(&r1).unwrap();
906 let d2 = ReceiptComposer::digest(&r2).unwrap();
907 assert_eq!(d1, d2);
908 }
909
910 fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
920 let mut m = make_manifest();
921 m.authorized_tools = tools.into_iter().map(String::from).collect();
922 m
923 }
924
925 #[test]
926 fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
927 let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); let events = vec![
932 mk(0, "root", EventType::SessionStarted),
933 mk(1, "agent", EventType::AgentCompletedProcess {
934 process_name: "rm -rf /".into(),
935 exit_code: Some(0),
936 duration_ms: Some(50),
937 command: Some("rm -rf /".into()),
938 }),
939 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
940 ];
941 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
942 let tu = receipt.tool_usage.expect("tool_usage must be populated");
943 assert!(
944 tu.unauthorized.iter().any(|t| t == "bash"),
945 "bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
946 tu.unauthorized, tu.actual,
947 );
948 }
949
950 #[test]
951 fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
952 let manifest = manifest_with_authorized(vec!["read_file", "bash"]); let events = vec![
954 mk(0, "root", EventType::SessionStarted),
955 mk(1, "agent", EventType::AgentWroteFile {
956 file_path: "src/secret.rs".into(),
957 digest: None, operation: Some("modified".into()),
958 additions: Some(10), deletions: Some(0),
959 }),
960 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
961 ];
962 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
963 let tu = receipt.tool_usage.expect("tool_usage must be populated");
964 assert!(
965 tu.unauthorized.iter().any(|t| t == "write_file"),
966 "write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
967 tu.unauthorized, tu.actual,
968 );
969 }
970
971 #[test]
972 fn cert_includes_read_write_bash_passes_clean_when_all_used() {
973 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
974 let events = vec![
975 mk(0, "root", EventType::SessionStarted),
976 mk(1, "agent", EventType::AgentReadFile { file_path: "package.json".into(), digest: None }),
977 mk(2, "agent", EventType::AgentWroteFile {
978 file_path: "src/lib.rs".into(),
979 digest: None, operation: Some("modified".into()),
980 additions: Some(5), deletions: Some(2),
981 }),
982 mk(3, "agent", EventType::AgentCompletedProcess {
983 process_name: "bun test".into(),
984 exit_code: Some(0), duration_ms: Some(2000),
985 command: Some("bun test".into()),
986 }),
987 mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(5000) }),
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.is_empty(),
993 "all tools declared in cert should pass clean; got unauthorized={:?}",
994 tu.unauthorized,
995 );
996 let actual_names: std::collections::BTreeSet<String> =
1000 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1001 assert!(actual_names.contains("read_file"));
1002 assert!(actual_names.contains("write_file"));
1003 assert!(actual_names.contains("bash"));
1004 }
1005
1006 #[test]
1007 fn webfetch_unauthorized_flagged_when_cert_omits_it() {
1008 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); let events = vec![
1010 mk(0, "root", EventType::SessionStarted),
1011 mk(1, "agent", EventType::AgentConnectedNetwork {
1012 destination: "evil.example.com".into(),
1013 port: Some(443),
1014 }),
1015 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1016 ];
1017 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1018 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1019 assert!(
1020 tu.unauthorized.iter().any(|t| t == "web_fetch"),
1021 "web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
1022 tu.unauthorized,
1023 );
1024 }
1025
1026 fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
1029 let mut e = mk(99, "agent", event_type);
1030 e.meta = Some(serde_json::json!({"source": source}));
1031 e
1032 }
1033
1034 #[test]
1035 fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
1036 let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
1039 let events = vec![
1040 mk(0, "root", EventType::SessionStarted),
1041 mk(1, "agent", EventType::AgentReadFile { file_path: "x".into(), digest: None }),
1042 mk(2, "agent", EventType::AgentWroteFile {
1043 file_path: "y".into(),
1044 digest: None, operation: None, additions: None, deletions: None,
1045 }),
1046 mk(3, "agent", EventType::AgentCompletedProcess {
1047 process_name: "z".into(),
1048 exit_code: Some(0), duration_ms: Some(1), command: None,
1049 }),
1050 mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1051 ];
1052 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1053 assert!(
1054 tu.unauthorized.is_empty(),
1055 "TitleCase declarations must authorize canonical snake_case actuals via aliases; \
1056 got unauthorized={:?}",
1057 tu.unauthorized,
1058 );
1059 }
1060
1061 #[test]
1062 fn edit_alias_authorizes_specialized_wrote_file() {
1063 let manifest = manifest_with_authorized(vec!["Edit"]);
1068 let events = vec![
1069 mk(0, "root", EventType::SessionStarted),
1070 mk(1, "agent", EventType::AgentWroteFile {
1071 file_path: "x".into(),
1072 digest: None, operation: None, additions: None, deletions: None,
1073 }),
1074 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1075 ];
1076 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1077 assert!(tu.unauthorized.is_empty(), "Edit alias must authorize write_file");
1078 }
1079
1080 #[test]
1081 fn git_reconcile_writes_dont_count_toward_tool_usage() {
1082 let manifest = manifest_with_authorized(vec!["read_file"]);
1086 let events = vec![
1087 mk(0, "root", EventType::SessionStarted),
1088 evt_with_source(
1089 EventType::AgentWroteFile {
1090 file_path: "CHANGELOG.md".into(),
1091 digest: None, operation: Some("modified".into()),
1092 additions: Some(7), deletions: Some(2),
1093 },
1094 "git-reconcile",
1095 ),
1096 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1097 ];
1098 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1099 assert!(
1100 !tu.unauthorized.iter().any(|t| t == "write_file"),
1101 "git-reconcile entries must NOT count toward tool_usage; \
1102 got unauthorized={:?}, actual={:?}",
1103 tu.unauthorized, tu.actual,
1104 );
1105 let actual_names: std::collections::BTreeSet<String> =
1106 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1107 assert!(!actual_names.contains("write_file"),
1108 "actual must not include backstop-only writes");
1109 }
1110
1111 #[test]
1120 fn hook_emitted_writes_still_count_toward_tool_usage() {
1121 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1124 mk(0, "root", EventType::SessionStarted),
1125 evt_with_source(
1126 EventType::AgentWroteFile {
1127 file_path: "src/x.rs".into(),
1128 digest: None, operation: None, additions: None, deletions: None,
1129 },
1130 "hook",
1131 ),
1132 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1133 ];
1134 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1135 assert!(
1136 tu.unauthorized.iter().any(|t| t == "write_file"),
1137 "hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
1138 tu.unauthorized,
1139 );
1140 }
1141
1142 #[test]
1143 fn legacy_untagged_writes_count_for_back_compat() {
1144 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1148 mk(0, "root", EventType::SessionStarted),
1149 mk(1, "agent", EventType::AgentWroteFile {
1150 file_path: "x".into(),
1151 digest: None, operation: None, additions: None, deletions: None,
1152 }),
1153 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1154 ];
1155 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1156 assert!(
1157 tu.unauthorized.iter().any(|t| t == "write_file"),
1158 "legacy untagged writes must count for back-compat",
1159 );
1160 }
1161}