Skip to main content

treeship_core/session/
receipt.rs

1//! Session Receipt composer.
2//!
3//! Builds the canonical Session Receipt JSON from session events,
4//! artifact store, and Merkle tree. The receipt is the composed
5//! package-level artifact that unifies an entire session.
6
7use 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
20/// Receipt type identifier.
21pub const RECEIPT_TYPE: &str = "treeship/session-receipt/v1";
22
23/// Current receipt schema version. Receipts without this field are treated
24/// as schema "0" and verified under legacy rules (pre-v0.9.0 shape).
25pub const RECEIPT_SCHEMA_VERSION: &str = "1";
26
27// ── Top-level receipt ────────────────────────────────────────────────
28
29/// The complete Session Receipt.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SessionReceipt {
32    /// Always "treeship/session-receipt/v1".
33    #[serde(rename = "type")]
34    pub type_: String,
35
36    /// Schema version. Absent on pre-v0.9.0 receipts (treated as "0").
37    /// Set to "1" for v0.9.0+ receipts.
38    #[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    /// Tool usage summary: declared vs actual tools used during the session.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub tool_usage: Option<ToolUsage>,
55}
56
57/// Tool authorization and usage summary for the session.
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct ToolUsage {
60    /// Tools declared as authorized (from declaration.json).
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub declared: Vec<String>,
63    /// Tools actually called during the session with invocation counts.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub actual: Vec<ToolUsageEntry>,
66    /// Tools called that were NOT in the declared list.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub unauthorized: Vec<String>,
69}
70
71/// A single tool's usage count.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ToolUsageEntry {
74    pub tool_name: String,
75    pub count: u32,
76}
77
78/// Session metadata section of the receipt.
79#[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    /// Ship ID this session ran under, parsed from the manifest actor URI
92    /// (`ship://<ship_id>`). Absent on pre-v0.9.0 receipts or when the actor
93    /// URI was not a ship:// URI (e.g. human://alice for a human-led session).
94    /// Cross-verification uses this to check that a receipt and a presented
95    /// Agent Certificate reference the same ship.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub ship_id: Option<String>,
98    /// Structured narrative for human review. All fields optional.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub narrative: Option<Narrative>,
101    /// Cumulative input tokens across all agents.
102    #[serde(default)]
103    pub total_tokens_in: u64,
104    /// Cumulative output tokens across all agents.
105    #[serde(default)]
106    pub total_tokens_out: u64,
107}
108
109/// Structured narrative for the session summary.
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111pub struct Narrative {
112    /// One-line headline: "Verifier refactor completed."
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub headline: Option<String>,
115    /// Multi-sentence summary of what happened.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub summary: Option<String>,
118    /// What should be reviewed before trusting the output.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub review: Option<String>,
121}
122
123/// A single timeline entry.
124#[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/// An artifact referenced in the session.
138#[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/// Proofs section of the receipt.
149#[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    /// Count of events.jsonl lines that were skipped during read_all
162    /// because they failed to deserialize. Set by session::close from
163    /// EventLog::read_all_with_stats. Codex adversarial review finding #8:
164    /// without this in-band signal, a receipt sealed after malformed
165    /// events were silently dropped looks complete to a verifier even
166    /// when it isn't. `treeship package verify` surfaces this as a WARN
167    /// when nonzero. Defaults to 0; absent on pre-v0.9.6 receipts so
168    /// they still verify byte-identical.
169    #[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    /// AUD-07: the git-diff backstop was unavailable at close even though git
176    /// worked at session start (start_commit_sha was captured). A file could
177    /// have changed via a non-AgentWroteFile channel and the only backstop
178    /// that would have caught it was disabled (`.git` removed, corrupt index,
179    /// PATH-poisoned git), so the "Files changed" ledger may be incomplete.
180    /// `package verify` WARNs on this. Absent on receipts sealed before this
181    /// field so they stay byte-identical.
182    #[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/// Merkle section of the receipt.
194#[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    /// Merkle format version byte. Drives the leaf/internal hash dispatch
204    /// at verify time. Absent on pre-v0.10.3 receipts — defaults to `1`
205    /// (no domain separation) so v0.10.2 receipts continue to verify.
206    /// New receipts always serialize `2` (RFC 9162 domain separation).
207    #[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        // Default newly-constructed sections to v2 — the in-the-wild
214        // "default = v1" behavior only triggers when serde fills the
215        // field for a JSON that omitted it (legacy receipts).
216        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/// A Merkle inclusion proof entry.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct InclusionProofEntry {
229    pub artifact_id: String,
230    pub leaf_index: usize,
231    pub proof: InclusionProof,
232}
233
234// ── Composer ─────────────────────────────────────────────────────────
235
236/// Composes a Session Receipt from events and artifacts.
237pub struct ReceiptComposer;
238
239impl ReceiptComposer {
240    /// Compose a receipt from a session manifest, events, and optional artifact entries.
241    pub fn compose(
242        manifest: &SessionManifest,
243        events: &[SessionEvent],
244        artifact_entries: Vec<ArtifactEntry>,
245    ) -> SessionReceipt {
246        // Build agent graph
247        let agent_graph = AgentGraph::from_events(events);
248
249        // Build side effects
250        let side_effects = SideEffects::from_events(events);
251
252        // Build timeline from all events
253        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        // Sort by (timestamp, sequence_no, event_id) for determinism
268        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        // Compute participants from graph
276        let participants = compute_participants(&agent_graph, manifest);
277
278        // Compute hosts and tools from events
279        let hosts = compute_hosts(events, &manifest.hosts);
280        let tools = compute_tools(events, &manifest.tools);
281
282        // Compute duration from the session close event if present
283        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        // Build Merkle tree from artifact IDs
292        let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
293
294        // Proofs section. zk_proofs_present defaults to false here;
295        // the CLI caller sets it to true after compose if proof files
296        // exist in the session directory.
297        let proofs = ProofsSection {
298            signature_count: artifact_entries.len() as u32,
299            // AUD-01: compose does NOT run a signature-verification pass over
300            // the artifacts, so this must not claim signatures were verified.
301            // A `true` here was a self-asserted "valid" flag baked into the
302            // signed receipt that a consumer could mistake for an independent
303            // verification result. It stays false unless a real verify pass
304            // sets it.
305            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, // Set by caller after compose (Codex #8)
310            reconcile_untracked_truncated: 0,
311            reconcile_untracked_cap: 0,
312            reconcile_degraded: false, // Set by caller after compose (AUD-07)
313        };
314
315        // Compute cost/token totals from agent graph
316        // Cost is deliberately not aggregated. See event.rs comment.
317        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        // Session section
321        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        // Render config
340        let render = RenderConfig {
341            title: manifest.name.clone(),
342            theme: None,
343            sections: RenderConfig::default_sections(),
344            generate_preview: true,
345        };
346
347        // Derive tool usage from side effects + manifest authorized_tools
348        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    /// Produce deterministic canonical JSON bytes from a receipt.
369    ///
370    /// Uses serde's field-declaration-order serialization for determinism.
371    /// The resulting bytes are suitable for hashing.
372    pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
373        serde_json::to_vec(receipt)
374    }
375
376    /// Compute SHA-256 digest of the canonical receipt JSON.
377    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
384// ── Helpers ──────────────────────────────────────────────────────────
385
386fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
387    use std::collections::BTreeSet;
388
389    let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
390    // Count unique agents
391    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    // Collect tool runtimes from events in manifest
398    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    // Find root agent (depth 0, first started)
405    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    // Find final output agent (last completed at max depth or last completed overall)
413    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    // Seed from manifest
439    for h in manifest_hosts {
440        hosts.insert(h.host_id.clone(), h.clone());
441    }
442
443    // Discover from events
444    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    // Seed from manifest
462    for t in manifest_tools {
463        tools.insert(t.tool_id.clone(), t.clone());
464    }
465
466    // Count tool invocations from events
467    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    // Build inclusion proofs for each artifact
495    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
518/// Extract the ship_id from an actor URI of the form `ship://<id>`.
519/// Returns None for other URI schemes (human://, agent://) or malformed values.
520pub fn parse_ship_id_from_actor(actor: &str) -> Option<String> {
521    let rest = actor.strip_prefix("ship://")?;
522    // Strip any trailing path segment so `ship://ship_abc/foo` -> `ship_abc`.
523    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
531/// Extract a human-readable label from an EventType.
532/// Derive tool usage from side effects and the declared authorized tools list.
533///
534/// Bug Codex caught in adversarial review: previously this function counted
535/// only `side_effects.tool_invocations` (built from `EventType::AgentCalledTool`).
536/// But Claude Code's PostToolUse hook emits SPECIALIZED events for built-in
537/// tools (`agent.wrote_file` for Write/Edit, `agent.completed_process` for
538/// Bash, `agent.read_file` for Read, etc) -- those events never landed in
539/// `tool_invocations`, so a certificate that omitted "Bash" or "Write"
540/// passed cross-verification cleanly even when the agent ran them.
541///
542/// The fix: also count side effects from specialized event types under
543/// canonical tool names that match what an operator would declare in
544/// `bounded_actions`. Naming follows Claude Code conventions (Read, Write,
545/// Bash, WebFetch) since those are the tools users actually declare. A
546/// cert that uses an alternate naming scheme (e.g. `files.write`) needs
547/// to declare both for now -- a future TODO is canonical mapping at the
548/// cert layer.
549/// Side-effect canonical mapping for tool authorization.
550///
551/// Each entry maps a side-effect bucket to a canonical tool name AND a
552/// list of accepted aliases. The canonical name is what gets recorded
553/// in `tool_usage.actual`. Any alias from the authorized_tools list
554/// counts as authorization for the canonical name.
555///
556/// Codex round-2 caught two bugs in the round-1 fix:
557///
558/// 1. The round-1 mapping used Claude-Code TitleCase ("Read", "Write",
559///    "Bash") but the existing CLI -- `treeship declare --tools
560///    read_file,write_file,bash` per declare.rs:80 and `treeship agent
561///    register --tools read_file,write_file,bash` per main.rs:226 --
562///    teaches users lowercase snake_case names. So a cert that follows
563///    the documented convention got every actual tool flagged as
564///    unauthorized. Aliases close that gap: declarations in either
565///    convention authorize the same canonical entry.
566///
567/// 2. The round-1 logic counted side effects regardless of provenance.
568///    `git-reconcile` synthetic writes (the backstop layer) registered
569///    as tool use even though no actual tool was directly attributed
570///    for them. A build script that touched a file made the receipt
571///    say "Write tool was used", and the cert had to authorize Write
572///    or fail cross-verify -- even though the agent never invoked any
573///    Write tool. Below, only direct-attribution sources (`hook`,
574///    `mcp`, `shell-wrap`, `session-event-cli`, and untagged legacy
575///    events) count toward tool usage. Backstop sources (`git-reconcile`,
576///    `daemon-atime`) surface in the receipt's "Files changed" section
577///    so the reader sees the change, but they do NOT claim that an
578///    agent tool was the proximate cause. See source_attributes_a_tool
579///    below for the authoritative allow list.
580const TOOL_ALIASES: &[(&str, &[&str])] = &[
581    // Canonical first; rest are accepted aliases.
582    ("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
598/// Returns true iff `source` represents a direct tool attribution that
599/// should count toward `tool_usage.actual`.
600///
601/// Direct attribution sources -- a real tool fired and the channel
602/// captured it:
603///   - `hook`              integration hook saw the tool fire
604///   - `mcp`               promoted from MCP-bridge agent.called_tool
605///   - `shell-wrap`        `treeship wrap` captured a shell command
606///   - `session-event-cli` `treeship session event` from a hook script.
607///                         The Claude Code plugin's PostToolUse hook
608///                         calls `treeship session event --type
609///                         agent.wrote_file --file X`, and the CLI
610///                         tags those as "session-event-cli" -- so
611///                         excluding this label would make every
612///                         claude-code-plugin event invisible to
613///                         cross-verify.
614///   - None                legacy untagged event (back-compat)
615///
616/// Backstop / inference sources -- a file changed but no tool was
617/// directly attributed. Surface in the receipt's "Files changed"
618/// section so the reader sees the change but they must NOT inflate
619/// tool_usage:
620///   - `git-reconcile`     git diff at session close
621///   - `daemon-atime`      atime-based file detection
622fn 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
629/// Counts side effects by canonical tool name, filtering out
630/// non-attribution sources (git-reconcile, daemon-atime).
631fn 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    // Generic agent.called_tool events use the tool's actual name.
665    // The MCP bridge writes meta.source = "mcp-bridge" (which is not
666    // in source_attributes_a_tool's allow list) but tool_invocations
667    // come ONLY from agent.called_tool, which is direct attribution
668    // by definition -- so count all of them, no source filter applies
669    // here. (The bridge tool name is the source.)
670    for inv in &side_effects.tool_invocations {
671        *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
672    }
673
674    // Specialized side effects, source-filtered: only direct
675    // attribution (hook / mcp / shell-wrap / untagged-legacy) counts.
676    // git-reconcile and friends surface in the "Files changed" section
677    // for the reader but do NOT inflate tool_usage.
678    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    // network_connections has no source field today; treat all as
695    // attributed (this matches the round-1 behavior since there's no
696    // backstop layer producing network entries).
697    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    // Authorization check uses alias resolution: an actual tool is
711    // unauthorized only if NONE of its aliases are in the declared
712    // list. So a declaration of "read_file" authorizes both "Read"
713    // (Claude convention) and "read_file" (CLI convention) when they
714    // produce the canonical "read_file" actual entry.
715    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
734/// Returns true if `actual_name` (or any of its declared aliases) is
735/// in the declared set. Aliases mean a cert can use either Claude
736/// convention or snake_case CLI convention and still authorize the
737/// same canonical bucket.
738fn is_authorized(actual_name: &str, declared_set: &std::collections::BTreeSet<&str>) -> bool {
739    // Direct hit: the declared set names this tool exactly.
740    if declared_set.contains(actual_name) {
741        return true;
742    }
743    // Alias hit: walk the canonical mapping and see if any alias of
744    // the canonical bucket the actual_name belongs to is in declared.
745    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
782/// Optional human-readable summary from an EventType.
783fn 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    /// Module-level event constructor so the tool-authorization regression
855    /// tests below can reuse it without each redefining the closure.
856    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); // root + worker
959        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        // And it shows up in canonical JSON.
980        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        // Simulate a pre-v0.9.0 receipt by composing one and stripping the
991        // schema_version field. Re-serializing must produce byte-identical
992        // output so the package-level determinism check keeps passing for
993        // old receipts that nobody can re-sign.
994        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; // mimic a legacy receipt
1004
1005        let original = ReceiptComposer::to_canonical_json(&receipt).unwrap();
1006        // Verify the field is omitted, not serialized as null.
1007        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    // ── Tool authorization regression tests (Codex finding #1) ──
1050    //
1051    // Specialized event types (agent.wrote_file, agent.completed_process,
1052    // agent.read_file) must contribute to tool_usage.actual so that a
1053    // certificate's bounded_actions list can correctly flag unauthorized
1054    // built-in tool usage. Before this fix, only agent.called_tool fed
1055    // tool_usage.actual, so a cert that omitted "Bash" still passed even
1056    // when the agent ran Bash via Claude Code's built-in.
1057
1058    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        // Cert uses CLI-documented snake_case names (declare.rs:80,
1067        // main.rs:226). Round-2 fix: canonical actual is "bash" not
1068        // "Bash"; round-1 was flagging mismatches the wrong way.
1069        let manifest = manifest_with_authorized(vec!["read_file", "write_file"]); // NO bash
1070        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"]); // NO write_file
1103        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        // The actual list uses canonical lowercase names that match what
1185        // `treeship declare --tools` and `treeship agent register --tools`
1186        // teach (declare.rs:80, main.rs:226).
1187        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"]); // NO web_fetch
1197        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    // ── Round-2 fix tests: alias matching + source filtering ──
1226
1227    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        // Operator declares Claude convention. Aliases map "Read" to
1236        // canonical "read_file", "Write" to "write_file", etc.
1237        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        // Operator declares "Edit" specifically. post-tool-use.sh
1292        // emits agent.wrote_file for Edit/MultiEdit alike, so the
1293        // canonical actual is "write_file". Edit is in the write_file
1294        // alias list, so the cert authorizes.
1295        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        // Backstop evidence -- not direct tool attribution.
1330        // A git-reconciled change must NOT make the cert require
1331        // write_file authorization, because no Write tool was invoked.
1332        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    // session-event-cli is a direct-attribution source -- the standard
1373    // label the CLI stamps on events emitted by claude-code-plugin's
1374    // PostToolUse hook. So it counts toward tool_usage.actual just like
1375    // hook/mcp/shell-wrap do. The end-to-end test for this lives in
1376    // the targeted acceptance suite (T1) rather than as a unit test
1377    // here, because it requires the full event-emission + receipt-
1378    // composition pipeline running through `treeship session event`.
1379
1380    #[test]
1381    fn hook_emitted_writes_still_count_toward_tool_usage() {
1382        // Positive case: regular hook-emitted write IS direct attribution.
1383        let manifest = manifest_with_authorized(vec!["read_file"]); // NO write_file
1384        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        // Pre-v0.9.6 events have no source tag. Treat as attributed
1418        // (back-compat: receipts produced before source labeling existed).
1419        let manifest = manifest_with_authorized(vec!["read_file"]); // NO write_file
1420        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}