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