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 #[serde(default, skip_serializing_if = "is_false")]
184 pub reconcile_degraded: bool,
185}
186
187fn is_zero_u32(n: &u32) -> bool { *n == 0 }
188fn is_false(b: &bool) -> bool { !*b }
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct MerkleSection {
193 pub leaf_count: usize,
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub root: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub checkpoint_id: Option<String>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub inclusion_proofs: Vec<InclusionProofEntry>,
200 #[serde(default = "crate::merkle::tree::default_merkle_version_v1")]
205 pub merkle_version: u8,
206}
207
208impl Default for MerkleSection {
209 fn default() -> Self {
210 Self {
214 leaf_count: 0,
215 root: None,
216 checkpoint_id: None,
217 inclusion_proofs: Vec::new(),
218 merkle_version: crate::merkle::tree::MERKLE_VERSION_V2,
219 }
220 }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct InclusionProofEntry {
226 pub artifact_id: String,
227 pub leaf_index: usize,
228 pub proof: InclusionProof,
229}
230
231pub struct ReceiptComposer;
235
236impl ReceiptComposer {
237 pub fn compose(
239 manifest: &SessionManifest,
240 events: &[SessionEvent],
241 artifact_entries: Vec<ArtifactEntry>,
242 ) -> SessionReceipt {
243 let agent_graph = AgentGraph::from_events(events);
245
246 let side_effects = SideEffects::from_events(events);
248
249 let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
251 TimelineEntry {
252 sequence_no: e.sequence_no,
253 timestamp: e.timestamp.clone(),
254 event_id: e.event_id.clone(),
255 event_type: event_type_label(&e.event_type),
256 agent_instance_id: e.agent_instance_id.clone(),
257 agent_name: e.agent_name.clone(),
258 host_id: e.host_id.clone(),
259 summary: event_summary(&e.event_type),
260 }
261 }).collect();
262
263 timeline.sort_by(|a, b| {
265 a.timestamp.cmp(&b.timestamp)
266 .then(a.sequence_no.cmp(&b.sequence_no))
267 .then(a.event_id.cmp(&b.event_id))
268 });
269
270 let participants = compute_participants(&agent_graph, manifest);
272
273 let hosts = compute_hosts(events, &manifest.hosts);
275 let tools = compute_tools(events, &manifest.tools);
276
277 let duration_ms = events.iter().find_map(|e| {
279 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
280 *duration_ms
281 } else {
282 None
283 }
284 });
285
286 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
288
289 let proofs = ProofsSection {
293 signature_count: artifact_entries.len() as u32,
294 signatures_valid: false,
301 merkle_root_valid: merkle_tree.is_some(),
302 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
303 zk_proofs_present: false,
304 event_log_skipped: 0, reconcile_untracked_truncated: 0,
306 reconcile_untracked_cap: 0,
307 reconcile_degraded: false, };
309
310 let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
313 let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
314
315 let session = SessionSection {
317 id: manifest.session_id.clone(),
318 name: manifest.name.clone(),
319 mode: manifest.mode.clone(),
320 started_at: manifest.started_at.clone(),
321 ended_at: manifest.closed_at.clone(),
322 status: manifest.status.clone(),
323 duration_ms,
324 ship_id: parse_ship_id_from_actor(&manifest.actor),
325 narrative: manifest.summary.as_ref().map(|s| Narrative {
326 headline: manifest.name.clone(),
327 summary: Some(s.clone()),
328 review: None,
329 }),
330 total_tokens_in,
331 total_tokens_out,
332 };
333
334 let render = RenderConfig {
336 title: manifest.name.clone(),
337 theme: None,
338 sections: RenderConfig::default_sections(),
339 generate_preview: true,
340 };
341
342 let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
344
345 SessionReceipt {
346 type_: RECEIPT_TYPE.into(),
347 schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
348 session,
349 participants,
350 hosts,
351 tools,
352 agent_graph,
353 timeline,
354 side_effects,
355 artifacts: artifact_entries,
356 proofs,
357 merkle: merkle_section,
358 render,
359 tool_usage,
360 }
361 }
362
363 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
368 serde_json::to_vec(receipt)
369 }
370
371 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
373 let bytes = Self::to_canonical_json(receipt)?;
374 let hash = Sha256::digest(&bytes);
375 Ok(format!("sha256:{}", hex::encode(hash)))
376 }
377}
378
379fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
382 use std::collections::BTreeSet;
383
384 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
385 let total_agents = graph.nodes.len() as u32;
387 let spawned_subagents = graph.spawn_count();
388 let handoffs = graph.handoff_count();
389 let max_depth = graph.max_depth();
390 let host_ids = graph.host_ids();
391
392 for tool in &manifest.tools {
394 if let Some(ref rt) = tool.tool_runtime_id {
395 tool_runtimes.insert(rt.clone());
396 }
397 }
398
399 let root = graph.nodes.iter()
401 .filter(|n| n.depth == 0)
402 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
403 .map(|n| n.agent_instance_id.clone());
404
405 let final_output = graph.nodes.iter()
407 .filter(|n| n.completed_at.is_some())
408 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
409 .map(|n| n.agent_instance_id.clone());
410
411 Participants {
412 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
413 final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
414 total_agents,
415 spawned_subagents,
416 handoffs,
417 max_depth,
418 hosts: host_ids.len() as u32,
419 tool_runtimes: tool_runtimes.len() as u32,
420 }
421}
422
423fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
424 use std::collections::BTreeMap;
425
426 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
427
428 for h in manifest_hosts {
430 hosts.insert(h.host_id.clone(), h.clone());
431 }
432
433 for e in events {
435 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
436 host_id: e.host_id.clone(),
437 hostname: None,
438 os: None,
439 arch: None,
440 });
441 }
442
443 hosts.into_values().collect()
444}
445
446fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
447 use std::collections::BTreeMap;
448
449 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
450
451 for t in manifest_tools {
453 tools.insert(t.tool_id.clone(), t.clone());
454 }
455
456 for e in events {
458 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
459 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
460 tool_id: tool_name.clone(),
461 tool_name: tool_name.clone(),
462 tool_runtime_id: e.tool_runtime_id.clone(),
463 invocation_count: 0,
464 });
465 entry.invocation_count += 1;
466 }
467 }
468
469 tools.into_values().collect()
470}
471
472fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
473 if artifacts.is_empty() {
474 return (MerkleSection::default(), None);
475 }
476
477 let mut tree = MerkleTree::new();
478 for art in artifacts {
479 tree.append(&art.artifact_id);
480 }
481
482 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
483
484 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
486 .filter_map(|(i, art)| {
487 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
488 artifact_id: art.artifact_id.clone(),
489 leaf_index: i,
490 proof,
491 })
492 })
493 .collect();
494
495 let section = MerkleSection {
496 leaf_count: artifacts.len(),
497 root,
498 checkpoint_id: None,
499 inclusion_proofs,
500 merkle_version: tree.version(),
501 };
502
503 (section, Some(tree))
504}
505
506pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
509 let rest = actor.strip_prefix("ship://")?;
510 let id = rest.split('/').next().unwrap_or(rest);
512 if id.is_empty() { None } else { Some(id.to_string()) }
513}
514
515const TOOL_ALIASES: &[(&str, &[&str])] = &[
565 ("read_file", &["read_file", "Read"]),
567 ("write_file", &["write_file", "Write", "Edit", "MultiEdit", "NotebookEdit", "edit_file"]),
568 ("bash", &["bash", "Bash", "shell"]),
569 ("web_fetch", &["web_fetch", "WebFetch", "webfetch"]),
570];
571
572fn source_attributes_a_tool(source: Option<&str>) -> bool {
597 matches!(
598 source,
599 None
600 | Some("hook")
601 | Some("mcp")
602 | Some("shell-wrap")
603 | Some("session-event-cli"),
604 )
605}
606
607fn count_attributed<'a, F>(
610 items: usize,
611 source_at: F,
612 canonical: &str,
613 counts: &mut std::collections::BTreeMap<String, u32>,
614)
615where
616 F: Fn(usize) -> Option<&'a str>,
617{
618 let n: u32 = (0..items)
619 .filter(|i| source_attributes_a_tool(source_at(*i)))
620 .count() as u32;
621 if n > 0 {
622 *counts.entry(canonical.to_string()).or_insert(0) += n;
623 }
624}
625
626fn derive_tool_usage(
627 side_effects: &SideEffects,
628 authorized_tools: &[String],
629) -> Option<ToolUsage> {
630 use std::collections::BTreeMap;
631
632 let total_specialized = side_effects.files_read.len()
633 + side_effects.files_written.len()
634 + side_effects.processes.len()
635 + side_effects.network_connections.len();
636
637 if side_effects.tool_invocations.is_empty()
638 && total_specialized == 0
639 && authorized_tools.is_empty()
640 {
641 return None;
642 }
643
644 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
645
646 for inv in &side_effects.tool_invocations {
653 *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
654 }
655
656 let fr = &side_effects.files_read;
661 count_attributed(
662 fr.len(),
663 |i| fr[i].source.as_deref(),
664 "read_file",
665 &mut counts,
666 );
667 let fw = &side_effects.files_written;
668 count_attributed(
669 fw.len(),
670 |i| fw[i].source.as_deref(),
671 "write_file",
672 &mut counts,
673 );
674 let pr = &side_effects.processes;
675 count_attributed(
676 pr.len(),
677 |i| pr[i].source.as_deref(),
678 "bash",
679 &mut counts,
680 );
681 if !side_effects.network_connections.is_empty() {
685 *counts.entry("web_fetch".to_string()).or_insert(0) +=
686 side_effects.network_connections.len() as u32;
687 }
688
689 let actual: Vec<ToolUsageEntry> = counts.iter()
690 .map(|(name, &count)| ToolUsageEntry { tool_name: name.clone(), count })
691 .collect();
692
693 let unauthorized = if authorized_tools.is_empty() {
699 Vec::new()
700 } else {
701 let declared_set: std::collections::BTreeSet<&str> = authorized_tools.iter()
702 .map(|s| s.as_str())
703 .collect();
704 counts.keys()
705 .filter(|actual_name| !is_authorized(actual_name, &declared_set))
706 .cloned()
707 .collect()
708 };
709
710 Some(ToolUsage {
711 declared: authorized_tools.to_vec(),
712 actual,
713 unauthorized,
714 })
715}
716
717fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
722 if declared_set.contains(actual_name) {
724 return true;
725 }
726 for (canonical, aliases) in TOOL_ALIASES {
729 if *canonical == actual_name || aliases.contains(&actual_name) {
730 for alias in *aliases {
731 if declared_set.contains(*alias) {
732 return true;
733 }
734 }
735 return false;
736 }
737 }
738 false
739}
740
741fn event_type_label(et: &super::event::EventType) -> String {
742 use super::event::EventType::*;
743 match et {
744 SessionStarted => "session.started",
745 SessionClosed { .. } => "session.closed",
746 AgentStarted { .. } => "agent.started",
747 AgentSpawned { .. } => "agent.spawned",
748 AgentHandoff { .. } => "agent.handoff",
749 AgentCollaborated { .. } => "agent.collaborated",
750 AgentReturned { .. } => "agent.returned",
751 AgentCompleted { .. } => "agent.completed",
752 AgentFailed { .. } => "agent.failed",
753 AgentCalledTool { .. } => "agent.called_tool",
754 AgentReadFile { .. } => "agent.read_file",
755 AgentWroteFile { .. } => "agent.wrote_file",
756 AgentOpenedPort { .. } => "agent.opened_port",
757 AgentConnectedNetwork { .. } => "agent.connected_network",
758 AgentStartedProcess { .. } => "agent.started_process",
759 AgentCompletedProcess { .. } => "agent.completed_process",
760 AgentDecision { .. } => "agent.decision",
761 }.into()
762}
763
764fn event_summary(et: &super::event::EventType) -> Option<String> {
766 use super::event::EventType::*;
767 match et {
768 SessionStarted => Some("Session started".into()),
769 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
770 AgentSpawned { reason, .. } => reason.clone(),
771 AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
772 Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
773 }
774 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
775 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
776 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
777 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
778 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
779 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
780 AgentCompletedProcess { process_name, exit_code, .. } => {
781 Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
782 }
783 AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
784 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
785 AgentDecision { model, summary, provider, .. } => {
786 let mut parts = Vec::new();
787 if let Some(s) = summary { parts.push(s.clone()); }
788 if let Some(m) = model { parts.push(format!("model: {m}")); }
789 if let Some(p) = provider { parts.push(format!("via {p}")); }
790 if parts.is_empty() { Some("LLM decision".into()) } else { Some(parts.join(" | ")) }
791 }
792 _ => None,
793 }
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799 use crate::session::event::*;
800
801 fn make_manifest() -> SessionManifest {
802 SessionManifest::new(
803 "ssn_001".into(),
804 "agent://test".into(),
805 "2026-04-05T08:00:00Z".into(),
806 1743843600000,
807 )
808 }
809
810 fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
813 SessionEvent {
814 session_id: "ssn_001".into(),
815 event_id: format!("evt_{:016x}", seq),
816 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
817 sequence_no: seq,
818 trace_id: "trace_1".into(),
819 span_id: format!("span_{seq}"),
820 parent_span_id: None,
821 agent_id: format!("agent://{inst}"),
822 agent_instance_id: inst.into(),
823 agent_name: inst.into(),
824 agent_role: None,
825 host_id: "host_1".into(),
826 tool_runtime_id: None,
827 event_type: et,
828 artifact_ref: None,
829 meta: None,
830 }
831 }
832
833 fn make_events() -> Vec<SessionEvent> {
834 vec![
835 mk(0, "root", EventType::SessionStarted),
836 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
837 mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
838 mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
839 mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None, operation: None, additions: None, deletions: None }),
840 mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
841 mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
842 ]
843 }
844
845 #[test]
846 fn compose_receipt() {
847 let manifest = make_manifest();
848 let events = make_events();
849 let artifacts = vec![
850 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
851 ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
852 ];
853
854 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
855
856 assert_eq!(receipt.type_, RECEIPT_TYPE);
857 assert_eq!(receipt.session.id, "ssn_001");
858 assert_eq!(receipt.timeline.len(), 7);
859 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
861 assert_eq!(receipt.merkle.leaf_count, 2);
862 assert!(receipt.merkle.root.is_some());
863 }
864
865 #[test]
866 fn new_receipts_carry_schema_version() {
867 let manifest = make_manifest();
868 let events = make_events();
869 let artifacts = vec![
870 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
871 ];
872 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
873 assert_eq!(receipt.schema_version.as_deref(), Some(RECEIPT_SCHEMA_VERSION));
874 let json = String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
876 assert!(json.contains(r#""schema_version":"1""#), "missing schema_version: {json}");
877 }
878
879 #[test]
880 fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
881 let manifest = make_manifest();
886 let events = make_events();
887 let artifacts = vec![
888 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
889 ];
890 let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
891 receipt.schema_version = None; let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
894 let original_str = std::str::from_utf8(&original).unwrap();
896 assert!(!original_str.contains("schema_version"),
897 "schema_version must be skipped when None");
898
899 let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
900 assert!(parsed.schema_version.is_none(), "legacy receipts must parse with schema_version=None");
901
902 let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
903 assert_eq!(original, reserialized,
904 "legacy receipt must round-trip byte-identical so package determinism check passes");
905 }
906
907 #[test]
908 fn canonical_json_is_deterministic() {
909 let manifest = make_manifest();
910 let events = make_events();
911 let artifacts = vec![
912 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
913 ];
914
915 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
916 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
917
918 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
919 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
920 assert_eq!(j1, j2);
921
922 let d1 = ReceiptComposer::digest(&r1).unwrap();
923 let d2 = ReceiptComposer::digest(&r2).unwrap();
924 assert_eq!(d1, d2);
925 }
926
927 fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
937 let mut m = make_manifest();
938 m.authorized_tools = tools.into_iter().map(String::from).collect();
939 m
940 }
941
942 #[test]
943 fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
944 let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); let events = vec![
949 mk(0, "root", EventType::SessionStarted),
950 mk(1, "agent", EventType::AgentCompletedProcess {
951 process_name: "rm -rf /".into(),
952 exit_code: Some(0),
953 duration_ms: Some(50),
954 command: Some("rm -rf /".into()),
955 }),
956 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
957 ];
958 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
959 let tu = receipt.tool_usage.expect("tool_usage must be populated");
960 assert!(
961 tu.unauthorized.iter().any(|t| t == "bash"),
962 "bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
963 tu.unauthorized, tu.actual,
964 );
965 }
966
967 #[test]
968 fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
969 let manifest = manifest_with_authorized(vec!["read_file", "bash"]); let events = vec![
971 mk(0, "root", EventType::SessionStarted),
972 mk(1, "agent", EventType::AgentWroteFile {
973 file_path: "src/secret.rs".into(),
974 digest: None, operation: Some("modified".into()),
975 additions: Some(10), deletions: Some(0),
976 }),
977 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
978 ];
979 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
980 let tu = receipt.tool_usage.expect("tool_usage must be populated");
981 assert!(
982 tu.unauthorized.iter().any(|t| t == "write_file"),
983 "write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
984 tu.unauthorized, tu.actual,
985 );
986 }
987
988 #[test]
989 fn cert_includes_read_write_bash_passes_clean_when_all_used() {
990 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
991 let events = vec![
992 mk(0, "root", EventType::SessionStarted),
993 mk(1, "agent", EventType::AgentReadFile { file_path: "package.json".into(), digest: None }),
994 mk(2, "agent", EventType::AgentWroteFile {
995 file_path: "src/lib.rs".into(),
996 digest: None, operation: Some("modified".into()),
997 additions: Some(5), deletions: Some(2),
998 }),
999 mk(3, "agent", EventType::AgentCompletedProcess {
1000 process_name: "bun test".into(),
1001 exit_code: Some(0), duration_ms: Some(2000),
1002 command: Some("bun test".into()),
1003 }),
1004 mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(5000) }),
1005 ];
1006 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1007 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1008 assert!(
1009 tu.unauthorized.is_empty(),
1010 "all tools declared in cert should pass clean; got unauthorized={:?}",
1011 tu.unauthorized,
1012 );
1013 let actual_names: std::collections::BTreeSet<String> =
1017 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1018 assert!(actual_names.contains("read_file"));
1019 assert!(actual_names.contains("write_file"));
1020 assert!(actual_names.contains("bash"));
1021 }
1022
1023 #[test]
1024 fn webfetch_unauthorized_flagged_when_cert_omits_it() {
1025 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); let events = vec![
1027 mk(0, "root", EventType::SessionStarted),
1028 mk(1, "agent", EventType::AgentConnectedNetwork {
1029 destination: "evil.example.com".into(),
1030 port: Some(443),
1031 }),
1032 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1033 ];
1034 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1035 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1036 assert!(
1037 tu.unauthorized.iter().any(|t| t == "web_fetch"),
1038 "web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
1039 tu.unauthorized,
1040 );
1041 }
1042
1043 fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
1046 let mut e = mk(99, "agent", event_type);
1047 e.meta = Some(serde_json::json!({"source": source}));
1048 e
1049 }
1050
1051 #[test]
1052 fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
1053 let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
1056 let events = vec![
1057 mk(0, "root", EventType::SessionStarted),
1058 mk(1, "agent", EventType::AgentReadFile { file_path: "x".into(), digest: None }),
1059 mk(2, "agent", EventType::AgentWroteFile {
1060 file_path: "y".into(),
1061 digest: None, operation: None, additions: None, deletions: None,
1062 }),
1063 mk(3, "agent", EventType::AgentCompletedProcess {
1064 process_name: "z".into(),
1065 exit_code: Some(0), duration_ms: Some(1), command: None,
1066 }),
1067 mk(4, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1068 ];
1069 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1070 assert!(
1071 tu.unauthorized.is_empty(),
1072 "TitleCase declarations must authorize canonical snake_case actuals via aliases; \
1073 got unauthorized={:?}",
1074 tu.unauthorized,
1075 );
1076 }
1077
1078 #[test]
1079 fn edit_alias_authorizes_specialized_wrote_file() {
1080 let manifest = manifest_with_authorized(vec!["Edit"]);
1085 let events = vec![
1086 mk(0, "root", EventType::SessionStarted),
1087 mk(1, "agent", EventType::AgentWroteFile {
1088 file_path: "x".into(),
1089 digest: None, operation: None, additions: None, deletions: None,
1090 }),
1091 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1092 ];
1093 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1094 assert!(tu.unauthorized.is_empty(), "Edit alias must authorize write_file");
1095 }
1096
1097 #[test]
1098 fn git_reconcile_writes_dont_count_toward_tool_usage() {
1099 let manifest = manifest_with_authorized(vec!["read_file"]);
1103 let events = vec![
1104 mk(0, "root", EventType::SessionStarted),
1105 evt_with_source(
1106 EventType::AgentWroteFile {
1107 file_path: "CHANGELOG.md".into(),
1108 digest: None, operation: Some("modified".into()),
1109 additions: Some(7), deletions: Some(2),
1110 },
1111 "git-reconcile",
1112 ),
1113 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1114 ];
1115 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1116 assert!(
1117 !tu.unauthorized.iter().any(|t| t == "write_file"),
1118 "git-reconcile entries must NOT count toward tool_usage; \
1119 got unauthorized={:?}, actual={:?}",
1120 tu.unauthorized, tu.actual,
1121 );
1122 let actual_names: std::collections::BTreeSet<String> =
1123 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1124 assert!(!actual_names.contains("write_file"),
1125 "actual must not include backstop-only writes");
1126 }
1127
1128 #[test]
1137 fn hook_emitted_writes_still_count_toward_tool_usage() {
1138 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1141 mk(0, "root", EventType::SessionStarted),
1142 evt_with_source(
1143 EventType::AgentWroteFile {
1144 file_path: "src/x.rs".into(),
1145 digest: None, operation: None, additions: None, deletions: None,
1146 },
1147 "hook",
1148 ),
1149 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1150 ];
1151 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1152 assert!(
1153 tu.unauthorized.iter().any(|t| t == "write_file"),
1154 "hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
1155 tu.unauthorized,
1156 );
1157 }
1158
1159 #[test]
1160 fn legacy_untagged_writes_count_for_back_compat() {
1161 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1165 mk(0, "root", EventType::SessionStarted),
1166 mk(1, "agent", EventType::AgentWroteFile {
1167 file_path: "x".into(),
1168 digest: None, operation: None, additions: None, deletions: None,
1169 }),
1170 mk(2, "root", EventType::SessionClosed { summary: None, duration_ms: Some(1000) }),
1171 ];
1172 let tu = ReceiptComposer::compose(&manifest, &events, vec![]).tool_usage.unwrap();
1173 assert!(
1174 tu.unauthorized.iter().any(|t| t == "write_file"),
1175 "legacy untagged writes must count for back-compat",
1176 );
1177 }
1178}