Skip to main content

treeship_core/session/
side_effects.rs

1//! Side-effect aggregation from session events.
2//!
3//! Groups file, network, port, and process side effects for the
4//! side-effect ledger in the Session Report.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10use super::event::{EventType, SessionEvent};
11
12/// A file access event.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct FileAccess {
15    pub file_path: String,
16    pub agent_instance_id: String,
17    pub timestamp: String,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub digest: Option<String>,
20    /// "created", "modified", or "deleted". Absent for read events and legacy writes.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub operation: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub additions: Option<u32>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub deletions: Option<u32>,
27    /// Provenance: how the file change was witnessed.
28    /// - `"hook"`        : structured event from a native hook (e.g.
29    ///                     claude-code-plugin's PostToolUse → agent.wrote_file).
30    ///                     Highest trust -- the integration hook saw the
31    ///                     tool call directly with full input context.
32    /// - `"mcp"`         : promoted from a generic agent.called_tool event
33    ///                     by inspecting meta.tool_input.file_path. Medium
34    ///                     trust -- the MCP bridge saw the tool fire but
35    ///                     we inferred the direction (read vs write) from
36    ///                     tool name heuristics.
37    /// - `"git-reconcile"`: backstop -- collected from `git diff` at session
38    ///                     close to catch files an agent edited outside any
39    ///                     captured tool channel. Lowest direct trust but
40    ///                     highest completeness guarantee.
41    /// Absent on legacy receipts that predate this field.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub source: Option<String>,
44}
45
46/// A port opened by an agent.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PortAccess {
49    pub port: u16,
50    pub agent_instance_id: String,
51    pub timestamp: String,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub protocol: Option<String>,
54}
55
56/// A network connection made by an agent.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct NetworkConnection {
59    pub destination: String,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub port: Option<u16>,
62    pub agent_instance_id: String,
63    pub timestamp: String,
64}
65
66/// A process execution by an agent.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ProcessExecution {
69    pub process_name: String,
70    pub agent_instance_id: String,
71    pub started_at: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub exit_code: Option<i32>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub duration_ms: Option<u64>,
76    /// Full command string (e.g. "npm test --runInBand"). Absent in legacy events.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub command: Option<String>,
79    /// Same provenance scheme as FileAccess::source. `"hook"`, `"mcp"`,
80    /// `"shell-wrap"` (for treeship wrap'd commands), or absent on legacy.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub source: Option<String>,
83}
84
85/// A tool invocation by an agent.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ToolInvocation {
88    pub tool_name: String,
89    pub agent_instance_id: String,
90    pub timestamp: String,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub duration_ms: Option<u64>,
93}
94
95/// Aggregated side effects from a session.
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct SideEffects {
98    pub files_read: Vec<FileAccess>,
99    pub files_written: Vec<FileAccess>,
100    pub ports_opened: Vec<PortAccess>,
101    pub network_connections: Vec<NetworkConnection>,
102    pub processes: Vec<ProcessExecution>,
103    pub tool_invocations: Vec<ToolInvocation>,
104}
105
106impl SideEffects {
107    /// Build side effects from a sequence of session events.
108    pub fn from_events(events: &[SessionEvent]) -> Self {
109        let mut se = SideEffects::default();
110
111        // Track started processes so we can match with completed events.
112        // Key: (agent_instance_id, process_name)
113        let mut started_processes: BTreeMap<(String, String), usize> = BTreeMap::new();
114
115        for event in events {
116            match &event.event_type {
117                EventType::AgentReadFile { file_path, digest } => {
118                    se.files_read.push(FileAccess {
119                        file_path: file_path.clone(),
120                        agent_instance_id: event.agent_instance_id.clone(),
121                        timestamp: event.timestamp.clone(),
122                        digest: digest.clone(),
123                        operation: None,
124                        additions: None,
125                        deletions: None,
126                        source: Some(source_from_meta(event, "hook")),
127                    });
128                }
129
130                EventType::AgentWroteFile {
131                    file_path,
132                    digest,
133                    operation,
134                    additions,
135                    deletions,
136                } => {
137                    se.files_written.push(FileAccess {
138                        file_path: file_path.clone(),
139                        agent_instance_id: event.agent_instance_id.clone(),
140                        timestamp: event.timestamp.clone(),
141                        digest: digest.clone(),
142                        operation: operation.clone(),
143                        additions: *additions,
144                        deletions: *deletions,
145                        source: Some(source_from_meta(event, "hook")),
146                    });
147                }
148
149                EventType::AgentOpenedPort { port, protocol } => {
150                    se.ports_opened.push(PortAccess {
151                        port: *port,
152                        agent_instance_id: event.agent_instance_id.clone(),
153                        timestamp: event.timestamp.clone(),
154                        protocol: protocol.clone(),
155                    });
156                }
157
158                EventType::AgentConnectedNetwork { destination, port } => {
159                    se.network_connections.push(NetworkConnection {
160                        destination: destination.clone(),
161                        port: *port,
162                        agent_instance_id: event.agent_instance_id.clone(),
163                        timestamp: event.timestamp.clone(),
164                    });
165                }
166
167                EventType::AgentStartedProcess {
168                    process_name,
169                    pid: _,
170                    command,
171                } => {
172                    let idx = se.processes.len();
173                    se.processes.push(ProcessExecution {
174                        process_name: process_name.clone(),
175                        agent_instance_id: event.agent_instance_id.clone(),
176                        started_at: event.timestamp.clone(),
177                        exit_code: None,
178                        duration_ms: None,
179                        command: command.clone(),
180                        source: Some(source_from_meta(event, "hook")),
181                    });
182                    started_processes
183                        .insert((event.agent_instance_id.clone(), process_name.clone()), idx);
184                }
185
186                EventType::AgentCompletedProcess {
187                    process_name,
188                    exit_code,
189                    duration_ms,
190                    command,
191                } => {
192                    let key = (event.agent_instance_id.clone(), process_name.clone());
193                    if let Some(&idx) = started_processes.get(&key) {
194                        if let Some(proc) = se.processes.get_mut(idx) {
195                            proc.exit_code = *exit_code;
196                            proc.duration_ms = *duration_ms;
197                            if proc.command.is_none() {
198                                proc.command = command.clone();
199                            }
200                        }
201                    } else {
202                        se.processes.push(ProcessExecution {
203                            process_name: process_name.clone(),
204                            agent_instance_id: event.agent_instance_id.clone(),
205                            started_at: event.timestamp.clone(),
206                            exit_code: *exit_code,
207                            duration_ms: *duration_ms,
208                            command: command.clone(),
209                            source: Some(source_from_meta(event, "hook")),
210                        });
211                    }
212                }
213
214                EventType::AgentCalledTool {
215                    tool_name,
216                    duration_ms,
217                    ..
218                } => {
219                    se.tool_invocations.push(ToolInvocation {
220                        tool_name: tool_name.clone(),
221                        agent_instance_id: event.agent_instance_id.clone(),
222                        timestamp: event.timestamp.clone(),
223                        duration_ms: *duration_ms,
224                    });
225
226                    // ── MCP promotion path ───────────────────────────────
227                    // Generic agent.called_tool events emitted by MCP
228                    // bridges (or any tool channel that doesn't dispatch
229                    // into specialized event types) carry their useful
230                    // payload in meta. Inspect meta.tool_input.{file_path,
231                    // path, command} and promote to files_read /
232                    // files_written / processes so the receipt's "Files
233                    // changed" and "Commands run" sections actually
234                    // reflect the work, not just a count of tool calls.
235                    //
236                    // The bar (per the trust-fabric direction): if a file
237                    // changed during the session, it must appear in the
238                    // receipt. Without this promotion path, an agent
239                    // editing files via MCP-routed tools shows up as
240                    // "tool_invocations: N" with files_written empty --
241                    // a confidently incomplete audit trail.
242                    promote_mcp_called_tool(event, tool_name, &mut se);
243                }
244
245                _ => {}
246            }
247        }
248
249        se
250    }
251
252    /// Summary counts for display.
253    pub fn summary(&self) -> SideEffectSummary {
254        SideEffectSummary {
255            files_read: self.files_read.len() as u32,
256            files_written: self.files_written.len() as u32,
257            ports_opened: self.ports_opened.len() as u32,
258            network_connections: self.network_connections.len() as u32,
259            processes: self.processes.len() as u32,
260            tool_invocations: self.tool_invocations.len() as u32,
261        }
262    }
263}
264
265/// Summary counts of side effects.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct SideEffectSummary {
268    pub files_read: u32,
269    pub files_written: u32,
270    pub ports_opened: u32,
271    pub network_connections: u32,
272    pub processes: u32,
273    pub tool_invocations: u32,
274}
275
276// ---------------------------------------------------------------------------
277// MCP promotion helpers
278//
279// When an agent.called_tool event was emitted by an MCP bridge (or any
280// tool channel that doesn't dispatch into specialized event types), the
281// useful payload lives in `event.meta`. These helpers inspect known
282// shapes (`meta.tool_input.file_path`, `meta.tool_input.command`, the
283// flattened variants) and tool-name heuristics to promote the tool call
284// into the right side-effects bucket with `source: "mcp"`.
285//
286// Heuristics are intentionally generous on the write side: when the
287// tool name is ambiguous, we err toward files_written rather than
288// dropping the path. The trust-fabric bar is "if a file changed, it
289// must appear in the receipt" -- losing a real change is worse than
290// classifying a read as a write.
291// ---------------------------------------------------------------------------
292
293/// Read a string from event.meta following a dotted path
294/// (e.g. "tool_input.file_path"). Returns None if any segment is
295/// absent or the leaf isn't a string.
296fn meta_string(event: &SessionEvent, dotted_path: &str) -> Option<String> {
297    let mut cur = event.meta.as_ref()?;
298    for segment in dotted_path.split('.') {
299        cur = cur.get(segment)?;
300    }
301    cur.as_str().map(|s| s.to_string())
302}
303
304/// Try a list of dotted paths in priority order; first non-empty wins.
305fn first_meta_string(event: &SessionEvent, paths: &[&str]) -> Option<String> {
306    for path in paths {
307        if let Some(v) = meta_string(event, path) {
308            if !v.is_empty() {
309                return Some(v);
310            }
311        }
312    }
313    None
314}
315
316/// Pull the source label off event.meta if present, else return the
317/// default. Lets emitters tag their provenance in meta without each
318/// event variant needing a dedicated field.
319///
320/// Codex adversarial review caught a real provenance lie in finding #7:
321/// the original implementation only RECOGNIZED `"hook"`, `"mcp"`,
322/// `"git-reconcile"`, and `"shell-wrap"`, and FELL BACK TO THE DEFAULT
323/// (callers passed `"hook"`) for any other value. That meant events
324/// emitted by `treeship session event` (which the CLI tags with
325/// `meta.source = "session-event-cli"`) and the daemon's atime-based
326/// detection (which tags `"daemon-atime"`) were both silently downgraded
327/// into the highest-trust `"hook"` label on the receipt page. The
328/// receipt rendered things as observed-by-hook that were actually
329/// observed by lower-trust mechanisms -- a quiet but real trust lie.
330///
331/// Fix: when meta.source is present and is a non-empty string, preserve
332/// it verbatim. Honest provenance, even for labels we have not added a
333/// pretty pill for yet (the receipt page falls back to `via <source>`
334/// for unknown labels, see SOURCE_LABELS in the website's receipt page).
335/// Only fall back to `default` when meta.source is genuinely absent,
336/// which is the case for events that do not tag their source at all
337/// (e.g. legacy hook-emitted events from a pre-v0.9.6 plugin).
338fn source_from_meta(event: &SessionEvent, default: &str) -> String {
339    event
340        .meta
341        .as_ref()
342        .and_then(|m| m.get("source"))
343        .and_then(|v| v.as_str())
344        .filter(|s| !s.is_empty())
345        .map(|s| s.to_string())
346        .unwrap_or_else(|| default.to_string())
347}
348
349/// Classify a tool name into a side-effects bucket using string contains
350/// heuristics. Different agents call their file ops different things
351/// (Read vs read_file vs view, Write vs write_file vs save, Bash vs
352/// shell vs exec), so we match on substring rather than exact name.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354enum ToolCategory {
355    Read,
356    Write,
357    Process,
358    Unknown,
359}
360
361fn classify_tool(tool_name: &str) -> ToolCategory {
362    let t = tool_name.to_lowercase();
363    // Process / shell first -- "execute" matches before generic "exec".
364    if t.contains("bash")
365        || t.contains("shell")
366        || t.contains("exec")
367        || t.contains("run_command")
368        || t.contains("ran_command")
369    {
370        return ToolCategory::Process;
371    }
372    // Writes: any mutation verb wins. Order matters because "edit" is a
373    // strong signal even when the tool name also contains "file".
374    if t.contains("write")
375        || t.contains("edit")
376        || t.contains("create_file")
377        || t.contains("modify")
378        || t.contains("patch")
379        || t.contains("save_file")
380        || t.contains("delete_file")
381        || t.contains("remove_file")
382        || t.contains("rename_file")
383    {
384        return ToolCategory::Write;
385    }
386    // Reads
387    if t.contains("read")
388        || t.contains("view_file")
389        || t.contains("cat_file")
390        || t.contains("open_file")
391        || t.contains("get_file_contents")
392    {
393        return ToolCategory::Read;
394    }
395    ToolCategory::Unknown
396}
397
398fn promote_mcp_called_tool(event: &SessionEvent, tool_name: &str, se: &mut SideEffects) {
399    let category = classify_tool(tool_name);
400
401    // File path candidates -- check tool_input first (Claude Code +
402    // most MCP servers), then flattened (some bridges flatten meta).
403    let file_path = first_meta_string(
404        event,
405        &[
406            "tool_input.file_path",
407            "tool_input.path",
408            "tool_input.notebook_path",
409            "tool_input.target_file",
410            "file_path",
411            "path",
412        ],
413    );
414
415    // Command candidates for shell-style tools.
416    let command = first_meta_string(
417        event,
418        &["tool_input.command", "command", "tool_input.cmd", "cmd"],
419    );
420
421    match (category, file_path, command) {
422        (ToolCategory::Read, Some(p), _) => {
423            se.files_read.push(FileAccess {
424                file_path: p,
425                agent_instance_id: event.agent_instance_id.clone(),
426                timestamp: event.timestamp.clone(),
427                digest: None,
428                operation: None,
429                additions: None,
430                deletions: None,
431                source: Some("mcp".into()),
432            });
433        }
434        (ToolCategory::Write, Some(p), _) => {
435            se.files_written.push(FileAccess {
436                file_path: p,
437                agent_instance_id: event.agent_instance_id.clone(),
438                timestamp: event.timestamp.clone(),
439                digest: None,
440                operation: None,
441                additions: None,
442                deletions: None,
443                source: Some("mcp".into()),
444            });
445        }
446        (ToolCategory::Process, _, Some(cmd)) => {
447            // Trim long commands to a usable process_name; the full
448            // command string is preserved in `command`.
449            let short = cmd.chars().take(120).collect::<String>();
450            se.processes.push(ProcessExecution {
451                process_name: short,
452                agent_instance_id: event.agent_instance_id.clone(),
453                started_at: event.timestamp.clone(),
454                exit_code: None,
455                duration_ms: None,
456                command: Some(cmd),
457                source: Some("mcp".into()),
458            });
459        }
460        (ToolCategory::Unknown, Some(p), _) => {
461            // Tool name didn't match any known verb but a file path is
462            // present in meta. Conservative call: record as a write so
463            // the file at minimum surfaces in the receipt. The trust-
464            // fabric bar is completeness; misclassifying a read as a
465            // write is recoverable, dropping the path silently is not.
466            se.files_written.push(FileAccess {
467                file_path: p,
468                agent_instance_id: event.agent_instance_id.clone(),
469                timestamp: event.timestamp.clone(),
470                digest: None,
471                operation: None,
472                additions: None,
473                deletions: None,
474                source: Some("mcp".into()),
475            });
476        }
477        _ => {
478            // No usable payload -- the tool_invocation entry written by
479            // the caller above is the only record. Acceptable: this is
480            // the "tool call we know happened but nothing to promote"
481            // case (e.g. a search or list-files MCP tool).
482        }
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::session::event::*;
490
491    fn evt(event_type: EventType) -> SessionEvent {
492        SessionEvent {
493            session_id: "ssn_001".into(),
494            event_id: generate_event_id(),
495            timestamp: "2026-04-05T08:00:00Z".into(),
496            sequence_no: 0,
497            trace_id: "t".into(),
498            span_id: "s".into(),
499            parent_span_id: None,
500            agent_id: "agent://test".into(),
501            agent_instance_id: "ai_1".into(),
502            agent_name: "test".into(),
503            agent_role: None,
504            host_id: "h".into(),
505            tool_runtime_id: None,
506            event_type,
507            artifact_ref: None,
508            meta: None,
509        }
510    }
511
512    #[test]
513    fn aggregates_file_and_tool_events() {
514        let events = vec![
515            evt(EventType::AgentReadFile {
516                file_path: "src/main.rs".into(),
517                digest: None,
518            }),
519            evt(EventType::AgentWroteFile {
520                file_path: "src/lib.rs".into(),
521                digest: Some("sha256:abc".into()),
522                operation: Some("modified".into()),
523                additions: Some(10),
524                deletions: Some(3),
525            }),
526            evt(EventType::AgentCalledTool {
527                tool_name: "read_file".into(),
528                tool_input_digest: None,
529                tool_output_digest: None,
530                duration_ms: Some(10),
531            }),
532            evt(EventType::AgentCalledTool {
533                tool_name: "write_file".into(),
534                tool_input_digest: None,
535                tool_output_digest: None,
536                duration_ms: None,
537            }),
538        ];
539
540        let se = SideEffects::from_events(&events);
541        assert_eq!(se.files_read.len(), 1);
542        assert_eq!(se.files_written.len(), 1);
543        assert_eq!(se.tool_invocations.len(), 2);
544        let summary = se.summary();
545        assert_eq!(summary.tool_invocations, 2);
546    }
547
548    #[test]
549    fn matches_process_start_and_complete() {
550        let events = vec![
551            evt(EventType::AgentStartedProcess {
552                process_name: "npm test".into(),
553                pid: Some(1234),
554                command: Some("npm test --runInBand".into()),
555            }),
556            evt(EventType::AgentCompletedProcess {
557                process_name: "npm test".into(),
558                exit_code: Some(0),
559                duration_ms: Some(5000),
560                command: None,
561            }),
562        ];
563
564        let se = SideEffects::from_events(&events);
565        assert_eq!(se.processes.len(), 1);
566        assert_eq!(se.processes[0].exit_code, Some(0));
567        assert_eq!(se.processes[0].duration_ms, Some(5000));
568    }
569
570    /// Construct an agent.called_tool event with the given tool name and
571    /// arbitrary meta JSON. Used by MCP promotion tests below.
572    fn called_tool_with_meta(tool_name: &str, meta: serde_json::Value) -> SessionEvent {
573        let mut e = evt(EventType::AgentCalledTool {
574            tool_name: tool_name.into(),
575            tool_input_digest: None,
576            tool_output_digest: None,
577            duration_ms: None,
578        });
579        e.meta = Some(meta);
580        e
581    }
582
583    #[test]
584    fn hook_file_events_carry_source_hook() {
585        // Regression: every existing emission path must tag itself so the
586        // receipt page can render provenance ("observed via hook") on
587        // each file row.
588        let events = vec![
589            evt(EventType::AgentReadFile {
590                file_path: "src/a.rs".into(),
591                digest: None,
592            }),
593            evt(EventType::AgentWroteFile {
594                file_path: "src/b.rs".into(),
595                digest: None,
596                operation: None,
597                additions: None,
598                deletions: None,
599            }),
600        ];
601        let se = SideEffects::from_events(&events);
602        assert_eq!(se.files_read[0].source.as_deref(), Some("hook"));
603        assert_eq!(se.files_written[0].source.as_deref(), Some("hook"));
604    }
605
606    #[test]
607    fn mcp_called_tool_with_file_path_promotes_to_files_written() {
608        // The trust-fabric invariant: a file changed during the session
609        // must appear in the receipt. Even when the only signal is a
610        // generic agent.called_tool event with the path tucked inside
611        // meta.tool_input.file_path (the shape the engineer's events.jsonl
612        // had), the aggregator must surface it.
613        let events = vec![called_tool_with_meta(
614            "Edit",
615            serde_json::json!({
616                "source": "mcp-bridge",
617                "tool_input": { "file_path": "src/api/receipt.ts" },
618            }),
619        )];
620        let se = SideEffects::from_events(&events);
621        assert_eq!(
622            se.files_written.len(),
623            1,
624            "Edit with file_path must promote to files_written"
625        );
626        assert_eq!(se.files_written[0].file_path, "src/api/receipt.ts");
627        assert_eq!(se.files_written[0].source.as_deref(), Some("mcp"));
628        // tool_invocations also gets the entry -- the original record
629        // is preserved alongside the promotion.
630        assert_eq!(se.tool_invocations.len(), 1);
631    }
632
633    #[test]
634    fn mcp_read_tool_promotes_to_files_read() {
635        let events = vec![called_tool_with_meta(
636            "Read",
637            serde_json::json!({ "tool_input": { "file_path": "package.json" } }),
638        )];
639        let se = SideEffects::from_events(&events);
640        assert_eq!(se.files_read.len(), 1);
641        assert_eq!(se.files_read[0].file_path, "package.json");
642        assert_eq!(se.files_read[0].source.as_deref(), Some("mcp"));
643    }
644
645    #[test]
646    fn mcp_bash_tool_promotes_to_processes() {
647        let events = vec![called_tool_with_meta(
648            "Bash",
649            serde_json::json!({ "tool_input": { "command": "bun test --run" } }),
650        )];
651        let se = SideEffects::from_events(&events);
652        assert_eq!(se.processes.len(), 1);
653        assert_eq!(se.processes[0].command.as_deref(), Some("bun test --run"));
654        assert_eq!(se.processes[0].source.as_deref(), Some("mcp"));
655    }
656
657    #[test]
658    fn mcp_unknown_tool_with_path_defaults_to_files_written() {
659        // Trust-fabric bar: when the tool name doesn't match any known
660        // verb but meta carries a file_path, record as files_written
661        // rather than dropping the path. Misclassifying a read as a
662        // write is recoverable; silently losing a real change is not.
663        let events = vec![called_tool_with_meta(
664            "mcp__weird-vendor__do_thing",
665            serde_json::json!({ "tool_input": { "file_path": "config.toml" } }),
666        )];
667        let se = SideEffects::from_events(&events);
668        assert_eq!(se.files_written.len(), 1);
669        assert_eq!(se.files_written[0].file_path, "config.toml");
670        assert_eq!(se.files_written[0].source.as_deref(), Some("mcp"));
671    }
672
673    #[test]
674    fn mcp_called_tool_without_meta_does_not_promote() {
675        // Plain agent.called_tool with no useful meta: the
676        // tool_invocation entry is the only record. We do NOT invent a
677        // file path or fail loudly -- this is the "search/list/info"
678        // tool case that legitimately has no side effect.
679        let events = vec![called_tool_with_meta(
680            "ls",
681            serde_json::json!({"source": "mcp-bridge"}),
682        )];
683        let se = SideEffects::from_events(&events);
684        assert_eq!(se.files_read.len(), 0);
685        assert_eq!(se.files_written.len(), 0);
686        assert_eq!(se.processes.len(), 0);
687        assert_eq!(se.tool_invocations.len(), 1);
688    }
689
690    #[test]
691    fn mcp_promotion_handles_alt_path_field_names() {
692        // Different MCP servers use different conventions for the path
693        // field. We try a list of common ones.
694        for path_field in &[
695            "tool_input.path",
696            "tool_input.target_file",
697            "file_path",
698            "path",
699        ] {
700            let mut meta_obj = serde_json::Map::new();
701            // Build a nested object matching the dotted path.
702            let parts: Vec<&str> = path_field.split('.').collect();
703            if parts.len() == 1 {
704                meta_obj.insert(parts[0].into(), serde_json::json!("x.txt"));
705            } else {
706                let inner = serde_json::json!({ parts[1]: "x.txt" });
707                meta_obj.insert(parts[0].into(), inner);
708            }
709            let events = vec![called_tool_with_meta(
710                "Edit",
711                serde_json::Value::Object(meta_obj),
712            )];
713            let se = SideEffects::from_events(&events);
714            assert_eq!(
715                se.files_written.len(),
716                1,
717                "expected promotion via {} but got nothing",
718                path_field,
719            );
720        }
721    }
722
723    // ── source_from_meta provenance preservation (Codex finding #7) ──
724    //
725    // Unknown labels must NOT be downgraded to "hook" -- the original
726    // implementation silently mapped session-event-cli, daemon-atime,
727    // and any other unrecognized value to the highest-trust "hook"
728    // bucket, lying to receipt readers about how the change was
729    // witnessed. The fix preserves the exact meta.source string when
730    // present.
731
732    fn evt_with_meta(et: EventType, meta: serde_json::Value) -> SessionEvent {
733        let mut e = evt(et);
734        e.meta = Some(meta);
735        e
736    }
737
738    #[test]
739    fn source_from_meta_preserves_session_event_cli() {
740        // Events emitted via `treeship session event` have meta.source =
741        // "session-event-cli" (set in commands/session.rs::event). This
742        // must NOT render as "hook" on the receipt.
743        let events = vec![evt_with_meta(
744            EventType::AgentWroteFile {
745                file_path: "src/x.rs".into(),
746                digest: None,
747                operation: None,
748                additions: None,
749                deletions: None,
750            },
751            serde_json::json!({"source": "session-event-cli"}),
752        )];
753        let se = SideEffects::from_events(&events);
754        assert_eq!(
755            se.files_written[0].source.as_deref(),
756            Some("session-event-cli"),
757            "session-event-cli must be preserved verbatim, not downgraded to hook"
758        );
759    }
760
761    #[test]
762    fn source_from_meta_preserves_daemon_atime() {
763        // The daemon's atime-based file detection tags events with
764        // source = "daemon-atime" (lower trust than direct hook). Must
765        // not be silently promoted to hook.
766        let events = vec![evt_with_meta(
767            EventType::AgentReadFile {
768                file_path: "src/x.rs".into(),
769                digest: None,
770            },
771            serde_json::json!({"source": "daemon-atime"}),
772        )];
773        let se = SideEffects::from_events(&events);
774        assert_eq!(
775            se.files_read[0].source.as_deref(),
776            Some("daemon-atime"),
777            "daemon-atime must be preserved verbatim, not downgraded to hook"
778        );
779    }
780
781    #[test]
782    fn source_from_meta_preserves_arbitrary_unknown_label() {
783        // A future emitter (or a bug in the wild) might tag a brand-new
784        // source label. We preserve it so the receipt page renders
785        // "via <label>" -- honest provenance even for labels we do not
786        // have a styled pill for yet.
787        let events = vec![evt_with_meta(
788            EventType::AgentWroteFile {
789                file_path: "x".into(),
790                digest: None,
791                operation: None,
792                additions: None,
793                deletions: None,
794            },
795            serde_json::json!({"source": "future-bridge-v2"}),
796        )];
797        let se = SideEffects::from_events(&events);
798        assert_eq!(
799            se.files_written[0].source.as_deref(),
800            Some("future-bridge-v2")
801        );
802    }
803
804    #[test]
805    fn source_from_meta_falls_back_when_meta_source_absent() {
806        // Backward compat: events with no meta.source field at all keep
807        // getting tagged with the caller's default. This is the legacy
808        // hook-emitted-event path -- pre-v0.9.6 plugins did not tag
809        // their source, and the aggregator still tags those as "hook"
810        // because that is what they actually were.
811        let events = vec![evt(EventType::AgentWroteFile {
812            file_path: "x".into(),
813            digest: None,
814            operation: None,
815            additions: None,
816            deletions: None,
817        })];
818        let se = SideEffects::from_events(&events);
819        assert_eq!(se.files_written[0].source.as_deref(), Some("hook"));
820    }
821
822    #[test]
823    fn source_from_meta_treats_empty_string_as_absent() {
824        // Defensive: meta.source = "" should not render as a row with
825        // "via " (empty pill). Treat as absent and fall back to default.
826        let events = vec![evt_with_meta(
827            EventType::AgentReadFile {
828                file_path: "x".into(),
829                digest: None,
830            },
831            serde_json::json!({"source": ""}),
832        )];
833        let se = SideEffects::from_events(&events);
834        assert_eq!(se.files_read[0].source.as_deref(), Some("hook"));
835    }
836}