1use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::merkle::{InclusionProof, MerkleTree};
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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111pub struct Narrative {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub headline: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub summary: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub review: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct TimelineEntry {
126 pub sequence_no: u64,
127 pub timestamp: String,
128 pub event_id: String,
129 pub event_type: String,
130 pub agent_instance_id: String,
131 pub agent_name: String,
132 pub host_id: String,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub summary: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ArtifactEntry {
140 pub artifact_id: String,
141 pub payload_type: String,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub digest: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub signed_at: Option<String>,
146}
147
148#[derive(Debug, Clone, Default, Serialize, Deserialize)]
150pub struct ProofsSection {
151 #[serde(default)]
152 pub signature_count: u32,
153 #[serde(default)]
154 pub signatures_valid: bool,
155 #[serde(default)]
156 pub merkle_root_valid: bool,
157 #[serde(default)]
158 pub inclusion_proofs_count: u32,
159 #[serde(default)]
160 pub zk_proofs_present: bool,
161 #[serde(default, skip_serializing_if = "is_zero_u32")]
170 pub event_log_skipped: u32,
171 #[serde(default, skip_serializing_if = "is_zero_u32")]
172 pub reconcile_untracked_truncated: u32,
173 #[serde(default, skip_serializing_if = "is_zero_u32")]
174 pub reconcile_untracked_cap: u32,
175 #[serde(default, skip_serializing_if = "is_false")]
183 pub reconcile_degraded: bool,
184}
185
186fn is_zero_u32(n: &u32) -> bool {
187 *n == 0
188}
189fn is_false(b: &bool) -> bool {
190 !*b
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct MerkleSection {
196 pub leaf_count: usize,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub root: Option<String>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub checkpoint_id: Option<String>,
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 pub inclusion_proofs: Vec<InclusionProofEntry>,
203 #[serde(default = "crate::merkle::tree::default_merkle_version_v1")]
208 pub merkle_version: u8,
209}
210
211impl Default for MerkleSection {
212 fn default() -> Self {
213 Self {
217 leaf_count: 0,
218 root: None,
219 checkpoint_id: None,
220 inclusion_proofs: Vec::new(),
221 merkle_version: crate::merkle::tree::MERKLE_VERSION_V2,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct InclusionProofEntry {
229 pub artifact_id: String,
230 pub leaf_index: usize,
231 pub proof: InclusionProof,
232}
233
234pub struct ReceiptComposer;
238
239impl ReceiptComposer {
240 pub fn compose(
242 manifest: &SessionManifest,
243 events: &[SessionEvent],
244 artifact_entries: Vec<ArtifactEntry>,
245 ) -> SessionReceipt {
246 let agent_graph = AgentGraph::from_events(events);
248
249 let side_effects = SideEffects::from_events(events);
251
252 let mut timeline: Vec<TimelineEntry> = events
254 .iter()
255 .map(|e| TimelineEntry {
256 sequence_no: e.sequence_no,
257 timestamp: e.timestamp.clone(),
258 event_id: e.event_id.clone(),
259 event_type: event_type_label(&e.event_type),
260 agent_instance_id: e.agent_instance_id.clone(),
261 agent_name: e.agent_name.clone(),
262 host_id: e.host_id.clone(),
263 summary: event_summary(&e.event_type),
264 })
265 .collect();
266
267 timeline.sort_by(|a, b| {
269 a.timestamp
270 .cmp(&b.timestamp)
271 .then(a.sequence_no.cmp(&b.sequence_no))
272 .then(a.event_id.cmp(&b.event_id))
273 });
274
275 let participants = compute_participants(&agent_graph, manifest);
277
278 let hosts = compute_hosts(events, &manifest.hosts);
280 let tools = compute_tools(events, &manifest.tools);
281
282 let duration_ms = events.iter().find_map(|e| {
284 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
285 *duration_ms
286 } else {
287 None
288 }
289 });
290
291 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
293
294 let proofs = ProofsSection {
298 signature_count: artifact_entries.len() as u32,
299 signatures_valid: false,
306 merkle_root_valid: merkle_tree.is_some(),
307 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
308 zk_proofs_present: false,
309 event_log_skipped: 0, reconcile_untracked_truncated: 0,
311 reconcile_untracked_cap: 0,
312 reconcile_degraded: false, };
314
315 let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
318 let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
319
320 let session = SessionSection {
322 id: manifest.session_id.clone(),
323 name: manifest.name.clone(),
324 mode: manifest.mode.clone(),
325 started_at: manifest.started_at.clone(),
326 ended_at: manifest.closed_at.clone(),
327 status: manifest.status.clone(),
328 duration_ms,
329 ship_id: parse_ship_id_from_actor(&manifest.actor),
330 narrative: manifest.summary.as_ref().map(|s| Narrative {
331 headline: manifest.name.clone(),
332 summary: Some(s.clone()),
333 review: None,
334 }),
335 total_tokens_in,
336 total_tokens_out,
337 };
338
339 let render = RenderConfig {
341 title: manifest.name.clone(),
342 theme: None,
343 sections: RenderConfig::default_sections(),
344 generate_preview: true,
345 };
346
347 let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
349
350 SessionReceipt {
351 type_: RECEIPT_TYPE.into(),
352 schema_version: Some(RECEIPT_SCHEMA_VERSION.into()),
353 session,
354 participants,
355 hosts,
356 tools,
357 agent_graph,
358 timeline,
359 side_effects,
360 artifacts: artifact_entries,
361 proofs,
362 merkle: merkle_section,
363 render,
364 tool_usage,
365 }
366 }
367
368 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
373 serde_json::to_vec(receipt)
374 }
375
376 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
378 let bytes = Self::to_canonical_json(receipt)?;
379 let hash = Sha256::digest(&bytes);
380 Ok(format!("sha256:{}", hex::encode(hash)))
381 }
382}
383
384fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
387 use std::collections::BTreeSet;
388
389 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
390 let total_agents = graph.nodes.len() as u32;
392 let spawned_subagents = graph.spawn_count();
393 let handoffs = graph.handoff_count();
394 let max_depth = graph.max_depth();
395 let host_ids = graph.host_ids();
396
397 for tool in &manifest.tools {
399 if let Some(ref rt) = tool.tool_runtime_id {
400 tool_runtimes.insert(rt.clone());
401 }
402 }
403
404 let root = graph
406 .nodes
407 .iter()
408 .filter(|n| n.depth == 0)
409 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
410 .map(|n| n.agent_instance_id.clone());
411
412 let final_output = graph
414 .nodes
415 .iter()
416 .filter(|n| n.completed_at.is_some())
417 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
418 .map(|n| n.agent_instance_id.clone());
419
420 Participants {
421 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
422 final_output_agent_instance_id: final_output
423 .or(manifest.participants.final_output_agent_instance_id.clone()),
424 total_agents,
425 spawned_subagents,
426 handoffs,
427 max_depth,
428 hosts: host_ids.len() as u32,
429 tool_runtimes: tool_runtimes.len() as u32,
430 }
431}
432
433fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
434 use std::collections::BTreeMap;
435
436 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
437
438 for h in manifest_hosts {
440 hosts.insert(h.host_id.clone(), h.clone());
441 }
442
443 for e in events {
445 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
446 host_id: e.host_id.clone(),
447 hostname: None,
448 os: None,
449 arch: None,
450 });
451 }
452
453 hosts.into_values().collect()
454}
455
456fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
457 use std::collections::BTreeMap;
458
459 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
460
461 for t in manifest_tools {
463 tools.insert(t.tool_id.clone(), t.clone());
464 }
465
466 for e in events {
468 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
469 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
470 tool_id: tool_name.clone(),
471 tool_name: tool_name.clone(),
472 tool_runtime_id: e.tool_runtime_id.clone(),
473 invocation_count: 0,
474 });
475 entry.invocation_count += 1;
476 }
477 }
478
479 tools.into_values().collect()
480}
481
482fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
483 if artifacts.is_empty() {
484 return (MerkleSection::default(), None);
485 }
486
487 let mut tree = MerkleTree::new();
488 for art in artifacts {
489 tree.append(&art.artifact_id);
490 }
491
492 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
493
494 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts
496 .iter()
497 .enumerate()
498 .filter_map(|(i, art)| {
499 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
500 artifact_id: art.artifact_id.clone(),
501 leaf_index: i,
502 proof,
503 })
504 })
505 .collect();
506
507 let section = MerkleSection {
508 leaf_count: artifacts.len(),
509 root,
510 checkpoint_id: None,
511 inclusion_proofs,
512 merkle_version: tree.version(),
513 };
514
515 (section, Some(tree))
516}
517
518pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
521 let rest = actor.strip_prefix("ship://")?;
522 let id = rest.split('/').next().unwrap_or(rest);
524 if id.is_empty() {
525 None
526 } else {
527 Some(id.to_string())
528 }
529}
530
531const TOOL_ALIASES: &[(&str, &[&str])] = &[
581 ("read_file", &["read_file", "Read"]),
583 (
584 "write_file",
585 &[
586 "write_file",
587 "Write",
588 "Edit",
589 "MultiEdit",
590 "NotebookEdit",
591 "edit_file",
592 ],
593 ),
594 ("bash", &["bash", "Bash", "shell"]),
595 ("web_fetch", &["web_fetch", "WebFetch", "webfetch"]),
596];
597
598fn source_attributes_a_tool(source: Option<&str>) -> bool {
623 matches!(
624 source,
625 None | Some("hook") | Some("mcp") | Some("shell-wrap") | Some("session-event-cli"),
626 )
627}
628
629fn count_attributed<'a, F>(
632 items: usize,
633 source_at: F,
634 canonical: &str,
635 counts: &mut std::collections::BTreeMap<String, u32>,
636) where
637 F: Fn(usize) -> Option<&'a str>,
638{
639 let n: u32 = (0..items)
640 .filter(|i| source_attributes_a_tool(source_at(*i)))
641 .count() as u32;
642 if n > 0 {
643 *counts.entry(canonical.to_string()).or_insert(0) += n;
644 }
645}
646
647fn derive_tool_usage(side_effects: &SideEffects, authorized_tools: &[String]) -> Option<ToolUsage> {
648 use std::collections::BTreeMap;
649
650 let total_specialized = side_effects.files_read.len()
651 + side_effects.files_written.len()
652 + side_effects.processes.len()
653 + side_effects.network_connections.len();
654
655 if side_effects.tool_invocations.is_empty()
656 && total_specialized == 0
657 && authorized_tools.is_empty()
658 {
659 return None;
660 }
661
662 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
663
664 for inv in &side_effects.tool_invocations {
671 *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
672 }
673
674 let fr = &side_effects.files_read;
679 count_attributed(
680 fr.len(),
681 |i| fr[i].source.as_deref(),
682 "read_file",
683 &mut counts,
684 );
685 let fw = &side_effects.files_written;
686 count_attributed(
687 fw.len(),
688 |i| fw[i].source.as_deref(),
689 "write_file",
690 &mut counts,
691 );
692 let pr = &side_effects.processes;
693 count_attributed(pr.len(), |i| pr[i].source.as_deref(), "bash", &mut counts);
694 if !side_effects.network_connections.is_empty() {
698 *counts.entry("web_fetch".to_string()).or_insert(0) +=
699 side_effects.network_connections.len() as u32;
700 }
701
702 let actual: Vec<ToolUsageEntry> = counts
703 .iter()
704 .map(|(name, &count)| ToolUsageEntry {
705 tool_name: name.clone(),
706 count,
707 })
708 .collect();
709
710 let unauthorized = if authorized_tools.is_empty() {
716 Vec::new()
717 } else {
718 let declared_set: std::collections::BTreeSet<&str> =
719 authorized_tools.iter().map(|s| s.as_str()).collect();
720 counts
721 .keys()
722 .filter(|actual_name| !is_authorized(actual_name, &declared_set))
723 .cloned()
724 .collect()
725 };
726
727 Some(ToolUsage {
728 declared: authorized_tools.to_vec(),
729 actual,
730 unauthorized,
731 })
732}
733
734fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
739 if declared_set.contains(actual_name) {
741 return true;
742 }
743 for (canonical, aliases) in TOOL_ALIASES {
746 if *canonical == actual_name || aliases.contains(&actual_name) {
747 for alias in *aliases {
748 if declared_set.contains(*alias) {
749 return true;
750 }
751 }
752 return false;
753 }
754 }
755 false
756}
757
758fn event_type_label(et: &super::event::EventType) -> String {
759 use super::event::EventType::*;
760 match et {
761 SessionStarted => "session.started",
762 SessionClosed { .. } => "session.closed",
763 AgentStarted { .. } => "agent.started",
764 AgentSpawned { .. } => "agent.spawned",
765 AgentHandoff { .. } => "agent.handoff",
766 AgentCollaborated { .. } => "agent.collaborated",
767 AgentReturned { .. } => "agent.returned",
768 AgentCompleted { .. } => "agent.completed",
769 AgentFailed { .. } => "agent.failed",
770 AgentCalledTool { .. } => "agent.called_tool",
771 AgentReadFile { .. } => "agent.read_file",
772 AgentWroteFile { .. } => "agent.wrote_file",
773 AgentOpenedPort { .. } => "agent.opened_port",
774 AgentConnectedNetwork { .. } => "agent.connected_network",
775 AgentStartedProcess { .. } => "agent.started_process",
776 AgentCompletedProcess { .. } => "agent.completed_process",
777 AgentDecision { .. } => "agent.decision",
778 }
779 .into()
780}
781
782fn event_summary(et: &super::event::EventType) -> Option<String> {
784 use super::event::EventType::*;
785 match et {
786 SessionStarted => Some("Session started".into()),
787 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
788 AgentSpawned { reason, .. } => reason.clone(),
789 AgentHandoff {
790 from_agent_instance_id,
791 to_agent_instance_id,
792 ..
793 } => Some(format!(
794 "{from_agent_instance_id} -> {to_agent_instance_id}"
795 )),
796 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
797 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
798 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
799 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
800 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
801 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
802 AgentCompletedProcess {
803 process_name,
804 exit_code,
805 ..
806 } => Some(format!(
807 "Completed {process_name} (exit {})",
808 exit_code.unwrap_or(-1)
809 )),
810 AgentCompleted { termination_reason } => termination_reason
811 .clone()
812 .or(Some("Agent completed".into())),
813 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
814 AgentDecision {
815 model,
816 summary,
817 provider,
818 ..
819 } => {
820 let mut parts = Vec::new();
821 if let Some(s) = summary {
822 parts.push(s.clone());
823 }
824 if let Some(m) = model {
825 parts.push(format!("model: {m}"));
826 }
827 if let Some(p) = provider {
828 parts.push(format!("via {p}"));
829 }
830 if parts.is_empty() {
831 Some("LLM decision".into())
832 } else {
833 Some(parts.join(" | "))
834 }
835 }
836 _ => None,
837 }
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843 use crate::session::event::*;
844
845 fn make_manifest() -> SessionManifest {
846 SessionManifest::new(
847 "ssn_001".into(),
848 "agent://test".into(),
849 "2026-04-05T08:00:00Z".into(),
850 1743843600000,
851 )
852 }
853
854 fn mk(seq: u64, inst: &str, et: EventType) -> SessionEvent {
857 SessionEvent {
858 session_id: "ssn_001".into(),
859 event_id: format!("evt_{:016x}", seq),
860 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
861 sequence_no: seq,
862 trace_id: "trace_1".into(),
863 span_id: format!("span_{seq}"),
864 parent_span_id: None,
865 agent_id: format!("agent://{inst}"),
866 agent_instance_id: inst.into(),
867 agent_name: inst.into(),
868 agent_role: None,
869 host_id: "host_1".into(),
870 tool_runtime_id: None,
871 event_type: et,
872 artifact_ref: None,
873 meta: None,
874 }
875 }
876
877 fn make_events() -> Vec<SessionEvent> {
878 vec![
879 mk(0, "root", EventType::SessionStarted),
880 mk(
881 1,
882 "root",
883 EventType::AgentStarted {
884 parent_agent_instance_id: None,
885 },
886 ),
887 mk(
888 2,
889 "worker",
890 EventType::AgentSpawned {
891 spawned_by_agent_instance_id: "root".into(),
892 reason: Some("review".into()),
893 },
894 ),
895 mk(
896 3,
897 "worker",
898 EventType::AgentCalledTool {
899 tool_name: "read_file".into(),
900 tool_input_digest: None,
901 tool_output_digest: None,
902 duration_ms: Some(5),
903 },
904 ),
905 mk(
906 4,
907 "worker",
908 EventType::AgentWroteFile {
909 file_path: "src/fix.rs".into(),
910 digest: None,
911 operation: None,
912 additions: None,
913 deletions: None,
914 },
915 ),
916 mk(
917 5,
918 "worker",
919 EventType::AgentCompleted {
920 termination_reason: None,
921 },
922 ),
923 mk(
924 6,
925 "root",
926 EventType::SessionClosed {
927 summary: Some("Done".into()),
928 duration_ms: Some(360000),
929 },
930 ),
931 ]
932 }
933
934 #[test]
935 fn compose_receipt() {
936 let manifest = make_manifest();
937 let events = make_events();
938 let artifacts = vec![
939 ArtifactEntry {
940 artifact_id: "art_001".into(),
941 payload_type: "action".into(),
942 digest: None,
943 signed_at: None,
944 },
945 ArtifactEntry {
946 artifact_id: "art_002".into(),
947 payload_type: "action".into(),
948 digest: None,
949 signed_at: None,
950 },
951 ];
952
953 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
954
955 assert_eq!(receipt.type_, RECEIPT_TYPE);
956 assert_eq!(receipt.session.id, "ssn_001");
957 assert_eq!(receipt.timeline.len(), 7);
958 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
960 assert_eq!(receipt.merkle.leaf_count, 2);
961 assert!(receipt.merkle.root.is_some());
962 }
963
964 #[test]
965 fn new_receipts_carry_schema_version() {
966 let manifest = make_manifest();
967 let events = make_events();
968 let artifacts = vec![ArtifactEntry {
969 artifact_id: "art_001".into(),
970 payload_type: "action".into(),
971 digest: None,
972 signed_at: None,
973 }];
974 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
975 assert_eq!(
976 receipt.schema_version.as_deref(),
977 Some(RECEIPT_SCHEMA_VERSION)
978 );
979 let json =
981 String::from_utf8(ReceiptComposer::to_canonical_json(&receipt).unwrap()).unwrap();
982 assert!(
983 json.contains(r#""schema_version":"1""#),
984 "missing schema_version: {json}"
985 );
986 }
987
988 #[test]
989 fn legacy_receipt_without_schema_version_round_trips_byte_identical() {
990 let manifest = make_manifest();
995 let events = make_events();
996 let artifacts = vec![ArtifactEntry {
997 artifact_id: "art_001".into(),
998 payload_type: "action".into(),
999 digest: None,
1000 signed_at: None,
1001 }];
1002 let mut receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
1003 receipt.schema_version = None; let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
1006 let original_str = std::str::from_utf8(&original).unwrap();
1008 assert!(
1009 !original_str.contains("schema_version"),
1010 "schema_version must be skipped when None"
1011 );
1012
1013 let parsed: SessionReceipt = serde_json::from_slice(&original).unwrap();
1014 assert!(
1015 parsed.schema_version.is_none(),
1016 "legacy receipts must parse with schema_version=None"
1017 );
1018
1019 let reserialized = ReceiptComposer::to_canonical_json(&parsed).unwrap();
1020 assert_eq!(
1021 original, reserialized,
1022 "legacy receipt must round-trip byte-identical so package determinism check passes"
1023 );
1024 }
1025
1026 #[test]
1027 fn canonical_json_is_deterministic() {
1028 let manifest = make_manifest();
1029 let events = make_events();
1030 let artifacts = vec![ArtifactEntry {
1031 artifact_id: "art_001".into(),
1032 payload_type: "action".into(),
1033 digest: None,
1034 signed_at: None,
1035 }];
1036
1037 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
1038 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
1039
1040 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
1041 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
1042 assert_eq!(j1, j2);
1043
1044 let d1 = ReceiptComposer::digest(&r1).unwrap();
1045 let d2 = ReceiptComposer::digest(&r2).unwrap();
1046 assert_eq!(d1, d2);
1047 }
1048
1049 fn manifest_with_authorized(tools: Vec<&str>) -> SessionManifest {
1059 let mut m = make_manifest();
1060 m.authorized_tools = tools.into_iter().map(String::from).collect();
1061 m
1062 }
1063
1064 #[test]
1065 fn cert_omitting_bash_flags_unauthorized_when_session_runs_bash() {
1066 let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); let events = vec![
1071 mk(0, "root", EventType::SessionStarted),
1072 mk(
1073 1,
1074 "agent",
1075 EventType::AgentCompletedProcess {
1076 process_name: "rm -rf /".into(),
1077 exit_code: Some(0),
1078 duration_ms: Some(50),
1079 command: Some("rm -rf /".into()),
1080 },
1081 ),
1082 mk(
1083 2,
1084 "root",
1085 EventType::SessionClosed {
1086 summary: None,
1087 duration_ms: Some(1000),
1088 },
1089 ),
1090 ];
1091 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1092 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1093 assert!(
1094 tu.unauthorized.iter().any(|t| t == "bash"),
1095 "bash must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1096 tu.unauthorized, tu.actual,
1097 );
1098 }
1099
1100 #[test]
1101 fn cert_omitting_write_flags_unauthorized_when_session_writes_file() {
1102 let manifest = manifest_with_authorized(vec!["read_file", "bash"]); let events = vec![
1104 mk(0, "root", EventType::SessionStarted),
1105 mk(
1106 1,
1107 "agent",
1108 EventType::AgentWroteFile {
1109 file_path: "src/secret.rs".into(),
1110 digest: None,
1111 operation: Some("modified".into()),
1112 additions: Some(10),
1113 deletions: Some(0),
1114 },
1115 ),
1116 mk(
1117 2,
1118 "root",
1119 EventType::SessionClosed {
1120 summary: None,
1121 duration_ms: Some(1000),
1122 },
1123 ),
1124 ];
1125 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1126 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1127 assert!(
1128 tu.unauthorized.iter().any(|t| t == "write_file"),
1129 "write_file must be flagged as unauthorized when cert omits it; got unauthorized={:?}, actual={:?}",
1130 tu.unauthorized, tu.actual,
1131 );
1132 }
1133
1134 #[test]
1135 fn cert_includes_read_write_bash_passes_clean_when_all_used() {
1136 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]);
1137 let events = vec![
1138 mk(0, "root", EventType::SessionStarted),
1139 mk(
1140 1,
1141 "agent",
1142 EventType::AgentReadFile {
1143 file_path: "package.json".into(),
1144 digest: None,
1145 },
1146 ),
1147 mk(
1148 2,
1149 "agent",
1150 EventType::AgentWroteFile {
1151 file_path: "src/lib.rs".into(),
1152 digest: None,
1153 operation: Some("modified".into()),
1154 additions: Some(5),
1155 deletions: Some(2),
1156 },
1157 ),
1158 mk(
1159 3,
1160 "agent",
1161 EventType::AgentCompletedProcess {
1162 process_name: "bun test".into(),
1163 exit_code: Some(0),
1164 duration_ms: Some(2000),
1165 command: Some("bun test".into()),
1166 },
1167 ),
1168 mk(
1169 4,
1170 "root",
1171 EventType::SessionClosed {
1172 summary: None,
1173 duration_ms: Some(5000),
1174 },
1175 ),
1176 ];
1177 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1178 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1179 assert!(
1180 tu.unauthorized.is_empty(),
1181 "all tools declared in cert should pass clean; got unauthorized={:?}",
1182 tu.unauthorized,
1183 );
1184 let actual_names: std::collections::BTreeSet<String> =
1188 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1189 assert!(actual_names.contains("read_file"));
1190 assert!(actual_names.contains("write_file"));
1191 assert!(actual_names.contains("bash"));
1192 }
1193
1194 #[test]
1195 fn webfetch_unauthorized_flagged_when_cert_omits_it() {
1196 let manifest = manifest_with_authorized(vec!["read_file", "write_file", "bash"]); let events = vec![
1198 mk(0, "root", EventType::SessionStarted),
1199 mk(
1200 1,
1201 "agent",
1202 EventType::AgentConnectedNetwork {
1203 destination: "evil.example.com".into(),
1204 port: Some(443),
1205 },
1206 ),
1207 mk(
1208 2,
1209 "root",
1210 EventType::SessionClosed {
1211 summary: None,
1212 duration_ms: Some(1000),
1213 },
1214 ),
1215 ];
1216 let receipt = ReceiptComposer::compose(&manifest, &events, vec![]);
1217 let tu = receipt.tool_usage.expect("tool_usage must be populated");
1218 assert!(
1219 tu.unauthorized.iter().any(|t| t == "web_fetch"),
1220 "web_fetch must be flagged as unauthorized when cert omits it; got unauthorized={:?}",
1221 tu.unauthorized,
1222 );
1223 }
1224
1225 fn evt_with_source(event_type: EventType, source: &str) -> SessionEvent {
1228 let mut e = mk(99, "agent", event_type);
1229 e.meta = Some(serde_json::json!({"source": source}));
1230 e
1231 }
1232
1233 #[test]
1234 fn titlecase_cert_authorizes_canonical_snake_actuals_via_alias() {
1235 let manifest = manifest_with_authorized(vec!["Read", "Write", "Bash"]);
1238 let events = vec![
1239 mk(0, "root", EventType::SessionStarted),
1240 mk(
1241 1,
1242 "agent",
1243 EventType::AgentReadFile {
1244 file_path: "x".into(),
1245 digest: None,
1246 },
1247 ),
1248 mk(
1249 2,
1250 "agent",
1251 EventType::AgentWroteFile {
1252 file_path: "y".into(),
1253 digest: None,
1254 operation: None,
1255 additions: None,
1256 deletions: None,
1257 },
1258 ),
1259 mk(
1260 3,
1261 "agent",
1262 EventType::AgentCompletedProcess {
1263 process_name: "z".into(),
1264 exit_code: Some(0),
1265 duration_ms: Some(1),
1266 command: None,
1267 },
1268 ),
1269 mk(
1270 4,
1271 "root",
1272 EventType::SessionClosed {
1273 summary: None,
1274 duration_ms: Some(1000),
1275 },
1276 ),
1277 ];
1278 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1279 .tool_usage
1280 .unwrap();
1281 assert!(
1282 tu.unauthorized.is_empty(),
1283 "TitleCase declarations must authorize canonical snake_case actuals via aliases; \
1284 got unauthorized={:?}",
1285 tu.unauthorized,
1286 );
1287 }
1288
1289 #[test]
1290 fn edit_alias_authorizes_specialized_wrote_file() {
1291 let manifest = manifest_with_authorized(vec!["Edit"]);
1296 let events = vec![
1297 mk(0, "root", EventType::SessionStarted),
1298 mk(
1299 1,
1300 "agent",
1301 EventType::AgentWroteFile {
1302 file_path: "x".into(),
1303 digest: None,
1304 operation: None,
1305 additions: None,
1306 deletions: None,
1307 },
1308 ),
1309 mk(
1310 2,
1311 "root",
1312 EventType::SessionClosed {
1313 summary: None,
1314 duration_ms: Some(1000),
1315 },
1316 ),
1317 ];
1318 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1319 .tool_usage
1320 .unwrap();
1321 assert!(
1322 tu.unauthorized.is_empty(),
1323 "Edit alias must authorize write_file"
1324 );
1325 }
1326
1327 #[test]
1328 fn git_reconcile_writes_dont_count_toward_tool_usage() {
1329 let manifest = manifest_with_authorized(vec!["read_file"]);
1333 let events = vec![
1334 mk(0, "root", EventType::SessionStarted),
1335 evt_with_source(
1336 EventType::AgentWroteFile {
1337 file_path: "CHANGELOG.md".into(),
1338 digest: None,
1339 operation: Some("modified".into()),
1340 additions: Some(7),
1341 deletions: Some(2),
1342 },
1343 "git-reconcile",
1344 ),
1345 mk(
1346 2,
1347 "root",
1348 EventType::SessionClosed {
1349 summary: None,
1350 duration_ms: Some(1000),
1351 },
1352 ),
1353 ];
1354 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1355 .tool_usage
1356 .unwrap();
1357 assert!(
1358 !tu.unauthorized.iter().any(|t| t == "write_file"),
1359 "git-reconcile entries must NOT count toward tool_usage; \
1360 got unauthorized={:?}, actual={:?}",
1361 tu.unauthorized,
1362 tu.actual,
1363 );
1364 let actual_names: std::collections::BTreeSet<String> =
1365 tu.actual.iter().map(|e| e.tool_name.clone()).collect();
1366 assert!(
1367 !actual_names.contains("write_file"),
1368 "actual must not include backstop-only writes"
1369 );
1370 }
1371
1372 #[test]
1381 fn hook_emitted_writes_still_count_toward_tool_usage() {
1382 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1385 mk(0, "root", EventType::SessionStarted),
1386 evt_with_source(
1387 EventType::AgentWroteFile {
1388 file_path: "src/x.rs".into(),
1389 digest: None,
1390 operation: None,
1391 additions: None,
1392 deletions: None,
1393 },
1394 "hook",
1395 ),
1396 mk(
1397 2,
1398 "root",
1399 EventType::SessionClosed {
1400 summary: None,
1401 duration_ms: Some(1000),
1402 },
1403 ),
1404 ];
1405 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1406 .tool_usage
1407 .unwrap();
1408 assert!(
1409 tu.unauthorized.iter().any(|t| t == "write_file"),
1410 "hook-emitted writes MUST count toward tool_usage; got unauthorized={:?}",
1411 tu.unauthorized,
1412 );
1413 }
1414
1415 #[test]
1416 fn legacy_untagged_writes_count_for_back_compat() {
1417 let manifest = manifest_with_authorized(vec!["read_file"]); let events = vec![
1421 mk(0, "root", EventType::SessionStarted),
1422 mk(
1423 1,
1424 "agent",
1425 EventType::AgentWroteFile {
1426 file_path: "x".into(),
1427 digest: None,
1428 operation: None,
1429 additions: None,
1430 deletions: None,
1431 },
1432 ),
1433 mk(
1434 2,
1435 "root",
1436 EventType::SessionClosed {
1437 summary: None,
1438 duration_ms: Some(1000),
1439 },
1440 ),
1441 ];
1442 let tu = ReceiptComposer::compose(&manifest, &events, vec![])
1443 .tool_usage
1444 .unwrap();
1445 assert!(
1446 tu.unauthorized.iter().any(|t| t == "write_file"),
1447 "legacy untagged writes must count for back-compat",
1448 );
1449 }
1450}