Skip to main content

harn_vm/orchestration/policy/
effects.rs

1//! Typed effect records carried on `HandoffArtifact` envelopes.
2//!
3//! `EffectRecord` is the leaf payload that names a single side-effect a
4//! spawned child agent may exercise (e.g. `Net write to https://api.example`,
5//! `Fs read of /workspace/src`). The set sits on each handoff so the
6//! dispatcher (E5.4) and the OpenTrustGraph receipt chain (E5.5) can prove
7//! the child never escaped its parent's effect grant.
8//!
9//! Computation at spawn time walks the child's entrypoint module via the
10//! same capability analysis `harn graph --json` uses (issue HARN-#1758),
11//! plus a conservative AST walker for harness calls embedded in inline spawn
12//! configs. The two extraction paths feed one canonicalization step so
13//! downstream consumers see a single deduped, deterministically ordered list.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use serde::{Deserialize, Serialize};
18
19use harn_ir::{CallClassification, Capability, LiteralValue, NodeSemantics};
20use harn_parser::{Node, SNode};
21
22use super::CapabilityPolicy;
23
24/// Discriminator for the kind of effect captured. Matches the
25/// classification used by the OpenTrustGraph receipt format (E5.5).
26#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
27#[serde(tag = "kind", rename_all = "snake_case")]
28pub enum EffectKind {
29    /// Reads or writes against the host's stdio streams.
30    Stdio,
31    /// Filesystem access (read, write, list, delete, ...).
32    Fs,
33    /// Network access (HTTP, SSE, WebSocket).
34    Net,
35    /// LLM model calls — captures the provider and model when statically
36    /// known so the receipt chain can name the inference dependency.
37    Llm {
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        provider: Option<String>,
40        #[serde(default, skip_serializing_if = "Option::is_none")]
41        model: Option<String>,
42    },
43    /// Pipeline-declared tool dispatched through the agent loop.
44    Tool { name: String },
45    /// Bridged host capability call (`host_call(capability.operation, ...)`).
46    Hostcall { name: String },
47    /// Targeted delegation to a named persona / sub-agent identity.
48    Persona { id: String },
49    /// Spawn / sub-agent / worker dispatch primitives.
50    Spawn,
51}
52
53/// What kind of interaction the effect represents. Mirrors the
54/// `read | write | mutate | observe` taxonomy the receipt schema uses.
55#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
56#[serde(rename_all = "snake_case")]
57pub enum EffectScope {
58    /// Pure read: no observable state change for other actors.
59    Read,
60    /// Write that creates or replaces state owned by this effect.
61    Write,
62    /// Mutation of state that may already be observed by other actors.
63    Mutate,
64    /// Side-channel observation (stdio sink, telemetry emission, ...).
65    Observe,
66}
67
68/// Single typed effect carried on a `HandoffArtifact.effects` entry.
69///
70/// `resource` is an opaque, statically-known target identifier (path,
71/// URL, tool id, persona id). The dispatcher (E5.4) is free to enforce
72/// ⊆ against the resource string; when no resource can be derived the
73/// field stays `None`.
74#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
75pub struct EffectRecord {
76    pub kind: EffectKind,
77    pub scope: EffectScope,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub resource: Option<String>,
80}
81
82impl EffectRecord {
83    pub fn new(kind: EffectKind, scope: EffectScope) -> Self {
84        Self {
85            kind,
86            scope,
87            resource: None,
88        }
89    }
90
91    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
92        let resource = resource.into();
93        self.resource = if resource.is_empty() {
94            None
95        } else {
96            Some(resource)
97        };
98        self
99    }
100}
101
102/// Compute the effect set for a child agent's entrypoint module.
103///
104/// Parses `source`, walks the resulting AST via the same `harn_ir`
105/// capability analyzer that backs `harn graph --json`, and supplements it with
106/// a direct walk for harness calls in inline spawn configs. The result is
107/// deterministically ordered and deduplicated.
108///
109/// When `ceiling` is provided, the result is clamped to it: an effect
110/// is dropped if the ceiling's `capabilities` map is non-empty and does
111/// not allow the matching capability/op, or if the effect's
112/// `side_effect_level` exceeds the ceiling's `side_effect_level`. Empty
113/// ceilings are treated as "no constraint" — the same convention the
114/// rest of the policy machinery uses.
115pub fn compute_handoff_effects(
116    source: &str,
117    ceiling: Option<&CapabilityPolicy>,
118) -> Vec<EffectRecord> {
119    let Ok(program) = harn_parser::parse_source(source) else {
120        return Vec::new();
121    };
122    let mut collected: BTreeSet<EffectRecord> = BTreeSet::new();
123
124    // Builtin / host-call effects via the existing IR analyzer — same
125    // surface `harn graph --json` reads.
126    let report = harn_ir::analyze_program(&program);
127    for handler in &report.handlers {
128        for node in &handler.nodes {
129            let NodeSemantics::Call(call) = &node.semantics else {
130                continue;
131            };
132            for effect in effects_from_call(call) {
133                collected.insert(effect);
134            }
135        }
136    }
137
138    // Spawn preflight also wraps object-literal configs and inline closures
139    // where the IR handler pass cannot always attribute harness calls. Keep
140    // this broad direct pass so parent/child effect checks stay conservative.
141    for node in &program {
142        walk_for_harness_effects(node, &mut collected);
143    }
144
145    let mut effects: Vec<EffectRecord> = collected.into_iter().collect();
146    if let Some(ceiling) = ceiling {
147        effects.retain(|effect| effect_allowed_by_ceiling(effect, ceiling));
148    }
149    effects
150}
151
152fn effects_from_call(call: &harn_ir::CallSemantics) -> Vec<EffectRecord> {
153    // Primary extraction: name-based builtin recognition. This path
154    // carries the richest information (resource from literal args,
155    // provider/model on LLM calls) and is the authoritative shape.
156    if call.name == "__files_upload" || call.name == "upload" {
157        let mut effects = Vec::with_capacity(2);
158        let mut fs = EffectRecord::new(EffectKind::Fs, EffectScope::Read);
159        if let Some(path) = call.literal_args.first().and_then(literal_as_str) {
160            fs = fs.with_resource(path);
161        }
162        effects.push(fs);
163        let mut net = EffectRecord::new(EffectKind::Net, EffectScope::Write);
164        if let Some(provider) = call.literal_args.get(1).and_then(literal_as_str) {
165            net = net.with_resource(provider);
166        }
167        effects.push(net);
168        return effects;
169    }
170    if let Some(effect) = builtin_effect(&call.name) {
171        return vec![annotate_with_resource(effect, call)];
172    }
173    if call.name == "host_call" {
174        if let Some(operation) = call.literal_args.first().and_then(literal_as_str) {
175            return vec![EffectRecord::new(
176                EffectKind::Hostcall {
177                    name: operation.to_string(),
178                },
179                hostcall_scope(operation),
180            )];
181        }
182    }
183    // Fallback: pipeline-declared tools and other capabilities the
184    // builtin recognizer doesn't know about (custom tool_call dispatch,
185    // user-defined capability classifications). Only consulted when the
186    // primary extraction returns nothing — same call shouldn't produce
187    // two records with different `resource` fields just because two
188    // extraction paths happen to match.
189    if let CallClassification::Capabilities(capability_effects) = &call.classification {
190        return capability_effects
191            .iter()
192            .filter_map(capability_effect_to_record)
193            .collect();
194    }
195    Vec::new()
196}
197
198fn builtin_effect(name: &str) -> Option<EffectRecord> {
199    match name {
200        // stdio
201        "print" | "println" | "eprint" | "eprintln" | "write_stdout" | "write_stderr"
202        | "__io_print" | "__io_println" | "__io_eprint" | "__io_eprintln" | "__io_write_stdout"
203        | "__io_write_stderr" => Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Observe)),
204        "read_line" | "read_stdin" | "prompt_user" | "__io_read_line" => {
205            Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Read))
206        }
207
208        // fs reads
209        "read_file"
210        | "read_file_bytes"
211        | "read_file_result"
212        | "render"
213        | "render_prompt"
214        | "render_with_provenance"
215        | "find_text"
216        | "read_lines"
217        | "list_dir"
218        | "walk_dir"
219        | "glob"
220        | "file_exists"
221        | "stat" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Read)),
222
223        // fs writes
224        "write_file" | "write_file_bytes" | "append_file" | "mkdir" | "mkdtemp" | "copy_file"
225        | "move_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Write)),
226        "delete_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
227        "apply_edit" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
228
229        // network — mirrors `is_network_call` in harn-ir; the EffectKind
230        // is identical for every transport because the dispatcher (E5.4)
231        // enforces the ⊆ relation at the `Net` granularity, not per-verb.
232        "http_get"
233        | "http_post"
234        | "http_put"
235        | "http_patch"
236        | "http_delete"
237        | "http_request"
238        | "http_download"
239        | "http_session"
240        | "http_session_request"
241        | "http_session_close"
242        | "http_stream_open"
243        | "http_stream_read"
244        | "http_stream_close"
245        | "sse_connect"
246        | "sse_receive"
247        | "sse_close"
248        | "sse_server_response"
249        | "sse_server_send"
250        | "sse_server_heartbeat"
251        | "sse_server_flush"
252        | "sse_server_close"
253        | "sse_server_cancel"
254        | "websocket_connect"
255        | "websocket_accept"
256        | "websocket_send"
257        | "websocket_receive"
258        | "websocket_close"
259        | "websocket_route"
260        | "websocket_server"
261        | "websocket_server_close"
262        | "unix_socket_json_request"
263        | "__net_unix_socket_json_request" => {
264            Some(EffectRecord::new(EffectKind::Net, EffectScope::Write))
265        }
266
267        // llm
268        "llm_call"
269        | "llm_call_safe"
270        | "llm_stream_call"
271        | "llm_call_structured"
272        | "llm_call_structured_safe"
273        | "llm_call_structured_result"
274        | "llm_completion"
275        | "agent_llm_turn"
276        | "agent_turn"
277        | "agent_loop" => Some(EffectRecord::new(
278            EffectKind::Llm {
279                provider: None,
280                model: None,
281            },
282            EffectScope::Write,
283        )),
284        "llm_catalog" | "llm_provider_status" => Some(EffectRecord::new(
285            EffectKind::Llm {
286                provider: None,
287                model: None,
288            },
289            EffectScope::Read,
290        )),
291        "llm_catalog_refresh" => Some(EffectRecord::new(
292            EffectKind::Llm {
293                provider: None,
294                model: None,
295            },
296            EffectScope::Write,
297        )),
298
299        // spawn / worker dispatch
300        "spawn_agent"
301        | "send_input"
302        | "resume_agent"
303        | "wait_agent"
304        | "close_agent"
305        | "worker_trigger"
306        | "__host_sub_agent_run"
307        | "__host_worker_spawn"
308        | "__host_worker_send_input"
309        | "__host_worker_resume"
310        | "__host_worker_trigger"
311        | "__host_worker_wait"
312        | "__host_worker_close" => Some(EffectRecord::new(EffectKind::Spawn, EffectScope::Write)),
313
314        // pipeline-declared tools dispatched through tool_call
315        "tool_call" | "host_tool_call" => Some(EffectRecord::new(
316            EffectKind::Tool {
317                name: String::new(),
318            },
319            EffectScope::Write,
320        )),
321
322        _ => None,
323    }
324}
325
326fn annotate_with_resource(mut effect: EffectRecord, call: &harn_ir::CallSemantics) -> EffectRecord {
327    // Effect resources are best-effort: first literal arg (path / url /
328    // tool name) when statically derivable. Unknown literals stay
329    // unannotated rather than guessed.
330    match &mut effect.kind {
331        EffectKind::Llm { provider, model } => {
332            for arg in &call.literal_args {
333                if let LiteralValue::Dict(entries) = arg {
334                    if let Some(value) = entries.get("provider").and_then(literal_as_str) {
335                        *provider = Some(value.to_string());
336                    }
337                    if let Some(value) = entries.get("model").and_then(literal_as_str) {
338                        *model = Some(value.to_string());
339                    }
340                }
341            }
342        }
343        EffectKind::Tool { name } => {
344            if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
345                *name = value.to_string();
346            }
347        }
348        _ => {
349            if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
350                effect.resource = Some(value.to_string());
351            }
352        }
353    }
354    effect
355}
356
357fn capability_effect_to_record(effect: &harn_ir::CapabilityEffect) -> Option<EffectRecord> {
358    let (kind, scope) = match effect.capability {
359        Capability::WorkspaceMutation => (EffectKind::Fs, EffectScope::Mutate),
360        Capability::CommandExecution => (
361            EffectKind::Hostcall {
362                name: format!("process.{}", effect.operation),
363            },
364            EffectScope::Write,
365        ),
366        Capability::NetworkAccess => (EffectKind::Net, EffectScope::Write),
367        Capability::ConnectorAccess => (
368            EffectKind::Hostcall {
369                name: if effect.operation.is_empty() {
370                    "connector.call".to_string()
371                } else {
372                    format!("connector.{}", effect.operation)
373                },
374            },
375            EffectScope::Write,
376        ),
377        Capability::ModelCall => (
378            EffectKind::Llm {
379                provider: None,
380                model: None,
381            },
382            EffectScope::Write,
383        ),
384        Capability::WorkerDispatch => (EffectKind::Spawn, EffectScope::Write),
385        Capability::HumanApproval => return None,
386        Capability::AutonomyPolicy => return None,
387    };
388    let resource = effect.path.clone();
389    Some(EffectRecord {
390        kind,
391        scope,
392        resource,
393    })
394}
395
396fn hostcall_scope(operation: &str) -> EffectScope {
397    match operation {
398        op if op.starts_with("workspace.read") || op.starts_with("workspace.list") => {
399            EffectScope::Read
400        }
401        op if op.starts_with("workspace.write") || op == "workspace.apply_edit" => {
402            EffectScope::Mutate
403        }
404        op if op.starts_with("process.") => EffectScope::Write,
405        _ => EffectScope::Write,
406    }
407}
408
409fn literal_as_str(value: &LiteralValue) -> Option<&str> {
410    match value {
411        LiteralValue::String(value) | LiteralValue::Identifier(value) => Some(value.as_str()),
412        _ => None,
413    }
414}
415
416fn walk_for_harness_effects(node: &SNode, out: &mut BTreeSet<EffectRecord>) {
417    if let Some(effect) = harness_method_effect(node) {
418        out.insert(effect);
419    }
420    for child in child_nodes(node) {
421        walk_for_harness_effects(child, out);
422    }
423}
424
425fn harness_method_effect(node: &SNode) -> Option<EffectRecord> {
426    let (object, method) = match &node.node {
427        Node::MethodCall { object, method, .. }
428        | Node::OptionalMethodCall { object, method, .. } => (object, method),
429        _ => return None,
430    };
431    let (sub_handle, root) = harness_sub_handle(object)?;
432    if !is_harness_root(root) {
433        return None;
434    }
435    let (kind, scope) = match (sub_handle.as_str(), method.as_str()) {
436        ("stdio", "print" | "println" | "eprint" | "eprintln") => {
437            (EffectKind::Stdio, EffectScope::Observe)
438        }
439        ("stdio", "read_line" | "prompt") => (EffectKind::Stdio, EffectScope::Read),
440        ("term", "width" | "height" | "read_password") => (EffectKind::Stdio, EffectScope::Read),
441        ("clock", _) => return None,
442        ("env", "set" | "unset") => (
443            EffectKind::Hostcall {
444                name: "env.set".to_string(),
445            },
446            EffectScope::Mutate,
447        ),
448        ("env", _) => (
449            EffectKind::Hostcall {
450                name: "env.get".to_string(),
451            },
452            EffectScope::Read,
453        ),
454        ("random", _) => return None,
455        ("fs", "read_file" | "read_text" | "read" | "exists" | "list_dir" | "stat") => {
456            (EffectKind::Fs, EffectScope::Read)
457        }
458        ("fs", "write_file" | "write_text" | "append_file" | "mkdir" | "mkdtemp" | "copy_file") => {
459            (EffectKind::Fs, EffectScope::Write)
460        }
461        ("fs", "delete_file" | "delete" | "remove") => (EffectKind::Fs, EffectScope::Mutate),
462        ("fs", _) => (EffectKind::Fs, EffectScope::Read),
463        ("net", _) => (EffectKind::Net, EffectScope::Write),
464        ("process", "spawn_captured") => (
465            EffectKind::Hostcall {
466                name: "process.spawn_captured".to_string(),
467            },
468            EffectScope::Write,
469        ),
470        ("crypto", "sha256") => return None,
471        // System-introspection methods (`cpu`, `memory`, `gpus`,
472        // `temperature`, `platform`, `processes`) are pure host reads
473        // — no state mutation, no resource consumed. They're gated by
474        // the harness capability handle itself, so deny-by-default
475        // policies still block them, but they don't produce a typed
476        // effect record for child grant enforcement.
477        ("system", _) => return None,
478        ("llm", "catalog" | "providers") => (
479            EffectKind::Llm {
480                provider: None,
481                model: None,
482            },
483            EffectScope::Read,
484        ),
485        ("llm", _) => return None,
486        _ => return None,
487    };
488    Some(EffectRecord::new(kind, scope))
489}
490
491fn harness_sub_handle(node: &SNode) -> Option<(String, &SNode)> {
492    match &node.node {
493        Node::PropertyAccess { object, property }
494        | Node::OptionalPropertyAccess { object, property } => {
495            Some((property.clone(), object.as_ref()))
496        }
497        _ => None,
498    }
499}
500
501fn is_harness_root(node: &SNode) -> bool {
502    matches!(&node.node, Node::Identifier(name) if name == "harness")
503}
504
505fn child_nodes(node: &SNode) -> Vec<&SNode> {
506    let mut children: Vec<&SNode> = Vec::new();
507    match &node.node {
508        Node::AttributedDecl { inner, .. } => children.push(inner.as_ref()),
509        Node::Pipeline { body, .. }
510        | Node::FnDecl { body, .. }
511        | Node::ToolDecl { body, .. }
512        | Node::SpawnExpr { body }
513        | Node::Retry { body, .. }
514        | Node::TryExpr { body }
515        | Node::DeferStmt { body }
516        | Node::Block(body)
517        | Node::OverrideDecl { body, .. } => children.extend(body.iter()),
518        Node::MutexBlock { key, body } => {
519            children.extend(key.as_deref());
520            children.extend(body.iter());
521        }
522        Node::ImplBlock { methods, .. } => children.extend(methods.iter()),
523        Node::IfElse {
524            condition,
525            then_body,
526            else_body,
527        } => {
528            children.push(condition.as_ref());
529            children.extend(then_body.iter());
530            if let Some(else_body) = else_body.as_ref() {
531                children.extend(else_body.iter());
532            }
533        }
534        Node::ForIn { iterable, body, .. } => {
535            children.push(iterable.as_ref());
536            children.extend(body.iter());
537        }
538        Node::WhileLoop { condition, body } => {
539            children.push(condition.as_ref());
540            children.extend(body.iter());
541        }
542        Node::MatchExpr { value, arms } => {
543            children.push(value.as_ref());
544            for arm in arms {
545                if let Some(guard) = arm.guard.as_ref() {
546                    children.push(guard.as_ref());
547                }
548                children.extend(arm.body.iter());
549            }
550        }
551        Node::CostRoute { options, body } => {
552            for (_key, value) in options {
553                children.push(value);
554            }
555            children.extend(body.iter());
556        }
557        Node::ReturnStmt { value } => {
558            if let Some(value) = value.as_ref() {
559                children.push(value.as_ref());
560            }
561        }
562        Node::ThrowStmt { value } => children.push(value.as_ref()),
563        Node::TryCatch {
564            body,
565            catch_body,
566            finally_body,
567            ..
568        } => {
569            children.extend(body.iter());
570            children.extend(catch_body.iter());
571            if let Some(finally_body) = finally_body.as_ref() {
572                children.extend(finally_body.iter());
573            }
574        }
575        Node::SkillDecl { fields, .. } => {
576            for (_name, value) in fields {
577                children.push(value);
578            }
579        }
580        Node::EvalPackDecl {
581            fields,
582            body,
583            summarize,
584            ..
585        } => {
586            for (_name, value) in fields {
587                children.push(value);
588            }
589            children.extend(body.iter());
590            if let Some(summarize) = summarize.as_ref() {
591                children.extend(summarize.iter());
592            }
593        }
594        Node::LetBinding { value, .. } | Node::VarBinding { value, .. } => {
595            children.push(value.as_ref());
596        }
597        Node::ConstBinding { value, .. } => {
598            children.push(value.as_ref());
599        }
600        Node::DeadlineBlock { duration, body } => {
601            children.push(duration.as_ref());
602            children.extend(body.iter());
603        }
604        Node::YieldExpr { value } => {
605            if let Some(value) = value.as_ref() {
606                children.push(value.as_ref());
607            }
608        }
609        Node::EmitExpr { value } => children.push(value.as_ref()),
610        Node::GuardStmt {
611            condition,
612            else_body,
613        } => {
614            children.push(condition.as_ref());
615            children.extend(else_body.iter());
616        }
617        Node::RequireStmt { condition, message } => {
618            children.push(condition.as_ref());
619            if let Some(message) = message.as_ref() {
620                children.push(message.as_ref());
621            }
622        }
623        Node::HitlExpr { args, .. } => {
624            for arg in args {
625                children.push(&arg.value);
626            }
627        }
628        Node::Parallel {
629            expr,
630            body,
631            options,
632            ..
633        } => {
634            children.push(expr.as_ref());
635            children.extend(body.iter());
636            for (_key, value) in options {
637                children.push(value);
638            }
639        }
640        Node::SelectExpr {
641            cases,
642            timeout,
643            default_body,
644        } => {
645            for case in cases {
646                children.push(case.channel.as_ref());
647                children.extend(case.body.iter());
648            }
649            if let Some((duration, body)) = timeout.as_ref() {
650                children.push(duration.as_ref());
651                children.extend(body.iter());
652            }
653            if let Some(body) = default_body.as_ref() {
654                children.extend(body.iter());
655            }
656        }
657        Node::FunctionCall { args, .. } => children.extend(args.iter()),
658        Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
659            children.push(object.as_ref());
660            children.extend(args.iter());
661        }
662        Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
663            children.push(object.as_ref());
664        }
665        Node::SubscriptAccess { object, index }
666        | Node::OptionalSubscriptAccess { object, index } => {
667            children.push(object.as_ref());
668            children.push(index.as_ref());
669        }
670        Node::SliceAccess { object, start, end } => {
671            children.push(object.as_ref());
672            if let Some(start) = start.as_ref() {
673                children.push(start.as_ref());
674            }
675            if let Some(end) = end.as_ref() {
676                children.push(end.as_ref());
677            }
678        }
679        Node::BinaryOp { left, right, .. } => {
680            children.push(left.as_ref());
681            children.push(right.as_ref());
682        }
683        Node::UnaryOp { operand, .. } => children.push(operand.as_ref()),
684        Node::Ternary {
685            condition,
686            true_expr,
687            false_expr,
688        } => {
689            children.push(condition.as_ref());
690            children.push(true_expr.as_ref());
691            children.push(false_expr.as_ref());
692        }
693        Node::Assignment { target, value, .. } => {
694            children.push(target.as_ref());
695            children.push(value.as_ref());
696        }
697        Node::EnumConstruct { args, .. } => children.extend(args.iter()),
698        Node::StructConstruct { fields, .. } => {
699            for entry in fields {
700                children.push(&entry.key);
701                children.push(&entry.value);
702            }
703        }
704        Node::ListLiteral(items) => children.extend(items.iter()),
705        Node::DictLiteral(entries) => {
706            for entry in entries {
707                children.push(&entry.key);
708                children.push(&entry.value);
709            }
710        }
711        Node::Spread(inner) => children.push(inner.as_ref()),
712        Node::TryOperator { operand } | Node::TryStar { operand } => {
713            children.push(operand.as_ref());
714        }
715        Node::OrPattern(items) => children.extend(items.iter()),
716        Node::Closure { body, .. } => children.extend(body.iter()),
717        Node::RangeExpr { start, end, .. } => {
718            children.push(start.as_ref());
719            children.push(end.as_ref());
720        }
721        _ => {}
722    }
723    children
724}
725
726fn effect_allowed_by_ceiling(effect: &EffectRecord, ceiling: &CapabilityPolicy) -> bool {
727    if !ceiling.capabilities.is_empty() {
728        let (capability, op) = effect_capability_op(effect);
729        let allowed = ceiling
730            .capabilities
731            .get(capability)
732            .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op));
733        if !allowed {
734            return false;
735        }
736    }
737    if let Some(ceiling_level) = ceiling.side_effect_level.as_deref() {
738        let requested = side_effect_level_for(effect);
739        if requested_exceeds_ceiling(requested, ceiling_level) {
740            return false;
741        }
742    }
743    true
744}
745
746fn effect_capability_op(effect: &EffectRecord) -> (&'static str, &'static str) {
747    match (&effect.kind, effect.scope) {
748        (EffectKind::Stdio, EffectScope::Read) => ("stdio", "read"),
749        (EffectKind::Stdio, _) => ("stdio", "write"),
750        (EffectKind::Fs, EffectScope::Read) => ("workspace", "read_text"),
751        (EffectKind::Fs, EffectScope::Write) => ("workspace", "write_text"),
752        (EffectKind::Fs, EffectScope::Mutate) => ("workspace", "apply_edit"),
753        (EffectKind::Fs, EffectScope::Observe) => ("workspace", "exists"),
754        (EffectKind::Net, _) => ("network", "http"),
755        (EffectKind::Llm { .. }, EffectScope::Read) => ("llm", "catalog"),
756        (EffectKind::Llm { .. }, _) => ("llm", "call"),
757        (EffectKind::Tool { .. }, _) => ("host", "tool_call"),
758        (EffectKind::Hostcall { .. }, _) => ("connector", "call"),
759        (EffectKind::Persona { .. }, _) => ("worker", "dispatch"),
760        (EffectKind::Spawn, _) => ("worker", "dispatch"),
761    }
762}
763
764fn side_effect_level_for(effect: &EffectRecord) -> &'static str {
765    match (&effect.kind, effect.scope) {
766        (EffectKind::Stdio, _) => "read_only",
767        (EffectKind::Fs, EffectScope::Read | EffectScope::Observe) => "read_only",
768        (EffectKind::Fs, _) => "workspace_write",
769        (EffectKind::Net, _) => "network",
770        (EffectKind::Llm { .. }, EffectScope::Read) => "read_only",
771        (EffectKind::Llm { .. }, _) => "network",
772        (EffectKind::Tool { .. }, _) => "workspace_write",
773        (EffectKind::Hostcall { name }, _) if name.starts_with("process.") => "process_exec",
774        (EffectKind::Hostcall { .. }, _) => "read_only",
775        (EffectKind::Persona { .. }, _) => "workspace_write",
776        (EffectKind::Spawn, _) => "workspace_write",
777    }
778}
779
780fn requested_exceeds_ceiling(requested: &str, ceiling: &str) -> bool {
781    fn rank(value: &str) -> usize {
782        match value {
783            "none" => 0,
784            "read_only" => 1,
785            "workspace_write" => 2,
786            "process_exec" => 3,
787            "network" => 4,
788            _ => 5,
789        }
790    }
791    rank(requested) > rank(ceiling)
792}
793
794/// Round-trip a typed effect list through the `metadata` map a child
795/// spawn-config carries. Pipelines that pre-compute effects can stash
796/// them under `effects` and the spawn shim lifts them onto the handoff.
797pub fn effects_from_metadata(metadata: &BTreeMap<String, serde_json::Value>) -> Vec<EffectRecord> {
798    metadata
799        .get("effects")
800        .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
801        .unwrap_or_default()
802}
803
804/// Decide whether `child` is covered by `parent`. An effect is covered
805/// when the parent declares another record with the same kind family
806/// and a scope that is at least as permissive. `resource` is treated
807/// best-effort: when the parent carries a non-empty resource it must
808/// match the child's resource exactly (and the child's resource must be
809/// known); when the parent has no resource it covers any resource the
810/// child names. This is the core of E5.4's `HARN-CAP-301` enforcement —
811/// the dispatcher and the static analyzer share one implementation so
812/// preflight and runtime never disagree.
813fn parent_covers_child(parent: &EffectRecord, child: &EffectRecord) -> bool {
814    if !effect_kind_family_matches(&parent.kind, &child.kind) {
815        return false;
816    }
817    if !effect_scope_covers(parent.scope, child.scope) {
818        return false;
819    }
820    match (parent.resource.as_deref(), child.resource.as_deref()) {
821        (Some(""), _) => true,
822        (Some(parent_resource), Some(child_resource)) => parent_resource == child_resource,
823        (Some(_), None) => false,
824        (None, _) => true,
825    }
826}
827
828fn effect_kind_family_matches(parent: &EffectKind, child: &EffectKind) -> bool {
829    match (parent, child) {
830        (EffectKind::Stdio, EffectKind::Stdio)
831        | (EffectKind::Fs, EffectKind::Fs)
832        | (EffectKind::Net, EffectKind::Net)
833        | (EffectKind::Spawn, EffectKind::Spawn) => true,
834        (EffectKind::Llm { .. }, EffectKind::Llm { .. }) => true,
835        (
836            EffectKind::Tool {
837                name: parent_name, ..
838            },
839            EffectKind::Tool {
840                name: child_name, ..
841            },
842        ) => parent_name.is_empty() || parent_name == child_name,
843        (
844            EffectKind::Hostcall {
845                name: parent_name, ..
846            },
847            EffectKind::Hostcall {
848                name: child_name, ..
849            },
850        ) => parent_name.is_empty() || parent_name == child_name,
851        (EffectKind::Persona { id: parent_id }, EffectKind::Persona { id: child_id }) => {
852            parent_id.is_empty() || parent_id == child_id
853        }
854        _ => false,
855    }
856}
857
858fn effect_scope_covers(parent: EffectScope, child: EffectScope) -> bool {
859    fn rank(scope: EffectScope) -> u8 {
860        match scope {
861            EffectScope::Read => 1,
862            EffectScope::Observe => 1,
863            EffectScope::Write => 2,
864            EffectScope::Mutate => 3,
865        }
866    }
867    rank(parent) >= rank(child)
868}
869
870/// Compute the subset of `child` effects that are not covered by any
871/// record in `parent`. An empty parent set is treated as "no declared
872/// effects" — under E5.4 the dispatcher takes that to mean every child
873/// effect is a violation, because a child can never out-grant an
874/// undeclared parent. When `parent` is `None` enforcement is skipped
875/// entirely (the caller has decided no static ceiling applies).
876pub fn effect_subset_violations(
877    parent: Option<&[EffectRecord]>,
878    child: &[EffectRecord],
879) -> Vec<EffectRecord> {
880    let Some(parent) = parent else {
881        return Vec::new();
882    };
883    child
884        .iter()
885        .filter(|effect| {
886            !parent
887                .iter()
888                .any(|allowed| parent_covers_child(allowed, effect))
889        })
890        .cloned()
891        .collect()
892}
893
894/// Short human-readable label for `effect.kind` used in
895/// `EffectInheritanceViolation` messages and `HARN-CAP-301` diagnostics.
896pub fn effect_kind_label(kind: &EffectKind) -> String {
897    match kind {
898        EffectKind::Stdio => "stdio".to_string(),
899        EffectKind::Fs => "fs".to_string(),
900        EffectKind::Net => "net".to_string(),
901        EffectKind::Llm { provider, model } => match (provider.as_deref(), model.as_deref()) {
902            (Some(provider), Some(model)) => format!("llm:{provider}/{model}"),
903            (Some(provider), None) => format!("llm:{provider}"),
904            (None, Some(model)) => format!("llm:{model}"),
905            (None, None) => "llm".to_string(),
906        },
907        EffectKind::Tool { name } if !name.is_empty() => format!("tool:{name}"),
908        EffectKind::Tool { .. } => "tool".to_string(),
909        EffectKind::Hostcall { name } if !name.is_empty() => format!("hostcall:{name}"),
910        EffectKind::Hostcall { .. } => "hostcall".to_string(),
911        EffectKind::Persona { id } if !id.is_empty() => format!("persona:{id}"),
912        EffectKind::Persona { .. } => "persona".to_string(),
913        EffectKind::Spawn => "spawn".to_string(),
914    }
915}
916
917/// One-line summary suitable for diagnostic messages and deny events.
918pub fn effect_record_summary(effect: &EffectRecord) -> String {
919    let scope = match effect.scope {
920        EffectScope::Read => "read",
921        EffectScope::Write => "write",
922        EffectScope::Mutate => "mutate",
923        EffectScope::Observe => "observe",
924    };
925    match effect.resource.as_deref() {
926        Some(resource) if !resource.is_empty() => {
927            format!(
928                "{}:{} ({})",
929                effect_kind_label(&effect.kind),
930                scope,
931                resource
932            )
933        }
934        _ => format!("{}:{}", effect_kind_label(&effect.kind), scope),
935    }
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    #[test]
943    fn harness_net_call_yields_net_effect() {
944        let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test") }"#;
945        let effects = compute_handoff_effects(source, None);
946        assert!(
947            effects
948                .iter()
949                .any(|effect| matches!(effect.kind, EffectKind::Net)
950                    && effect.scope == EffectScope::Write),
951            "expected Net write effect, got {effects:?}"
952        );
953    }
954
955    #[test]
956    fn harness_process_spawn_captured_yields_process_hostcall_effect() {
957        let source =
958            r#"fn main(harness: Harness) { harness.process.spawn_captured({cmd: "printf"}) }"#;
959        let effects = compute_handoff_effects(source, None);
960        assert!(
961            effects.iter().any(|effect| {
962                matches!(
963                    &effect.kind,
964                    EffectKind::Hostcall { name } if name == "process.spawn_captured"
965                ) && effect.scope == EffectScope::Write
966            }),
967            "expected process hostcall write effect, got {effects:?}"
968        );
969    }
970
971    #[test]
972    fn http_get_builtin_yields_net_effect_with_resource() {
973        let source = r#"fn main() { http_get("https://example.test/api") }"#;
974        let effects = compute_handoff_effects(source, None);
975        let net = effects
976            .iter()
977            .find(|effect| matches!(effect.kind, EffectKind::Net))
978            .expect("net effect");
979        assert_eq!(net.scope, EffectScope::Write);
980        assert_eq!(net.resource.as_deref(), Some("https://example.test/api"));
981    }
982
983    #[test]
984    fn unix_socket_json_request_yields_net_effect_with_resource() {
985        let source = r#"fn main() { __net_unix_socket_json_request("/tmp/harn.sock", {}) }"#;
986        let effects = compute_handoff_effects(source, None);
987        let net = effects
988            .iter()
989            .find(|effect| matches!(effect.kind, EffectKind::Net))
990            .expect("net effect");
991        assert_eq!(net.scope, EffectScope::Write);
992        assert_eq!(net.resource.as_deref(), Some("/tmp/harn.sock"));
993    }
994
995    #[test]
996    fn files_upload_yields_fs_read_and_net_write_effects() {
997        let source = r#"fn main() { __files_upload("/tmp/input.pdf", "gemini") }"#;
998        let effects = compute_handoff_effects(source, None);
999        assert!(
1000            effects.iter().any(|effect| {
1001                matches!(effect.kind, EffectKind::Fs)
1002                    && effect.scope == EffectScope::Read
1003                    && effect.resource.as_deref() == Some("/tmp/input.pdf")
1004            }),
1005            "expected Fs read effect, got {effects:?}"
1006        );
1007        assert!(
1008            effects.iter().any(|effect| {
1009                matches!(effect.kind, EffectKind::Net)
1010                    && effect.scope == EffectScope::Write
1011                    && effect.resource.as_deref() == Some("gemini")
1012            }),
1013            "expected Net write effect, got {effects:?}"
1014        );
1015    }
1016
1017    #[test]
1018    fn std_files_upload_wrapper_yields_fs_read_and_net_write_effects() {
1019        let source = r#"
1020import { upload } from "std/files"
1021fn main() { upload("/tmp/input.pdf", "gemini") }
1022"#;
1023        let effects = compute_handoff_effects(source, None);
1024        assert!(
1025            effects.iter().any(|effect| {
1026                matches!(effect.kind, EffectKind::Fs)
1027                    && effect.scope == EffectScope::Read
1028                    && effect.resource.as_deref() == Some("/tmp/input.pdf")
1029            }),
1030            "expected Fs read effect, got {effects:?}"
1031        );
1032        assert!(
1033            effects.iter().any(|effect| {
1034                matches!(effect.kind, EffectKind::Net)
1035                    && effect.scope == EffectScope::Write
1036                    && effect.resource.as_deref() == Some("gemini")
1037            }),
1038            "expected Net write effect, got {effects:?}"
1039        );
1040    }
1041
1042    #[test]
1043    fn harness_fs_write_yields_fs_write_effect() {
1044        let source = r#"fn main(harness: Harness) { harness.fs.write_file("/tmp/out", "hi") }"#;
1045        let effects = compute_handoff_effects(source, None);
1046        assert!(
1047            effects
1048                .iter()
1049                .any(|effect| matches!(effect.kind, EffectKind::Fs)
1050                    && effect.scope == EffectScope::Write),
1051            "expected Fs write effect, got {effects:?}"
1052        );
1053    }
1054
1055    #[test]
1056    fn harness_term_read_password_yields_stdio_read_effect() {
1057        let source = r#"fn main(harness: Harness) { harness.term.read_password("password: ") }"#;
1058        let effects = compute_handoff_effects(source, None);
1059        assert!(
1060            effects
1061                .iter()
1062                .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1063                    && effect.scope == EffectScope::Read),
1064            "expected Stdio read effect, got {effects:?}"
1065        );
1066    }
1067
1068    #[test]
1069    fn harness_fs_mkdtemp_yields_fs_write_effect() {
1070        let source = r#"fn main(harness: Harness) { harness.fs.mkdtemp("harn-") }"#;
1071        let effects = compute_handoff_effects(source, None);
1072        assert!(
1073            effects
1074                .iter()
1075                .any(|effect| matches!(effect.kind, EffectKind::Fs)
1076                    && effect.scope == EffectScope::Write),
1077            "expected Fs write effect, got {effects:?}"
1078        );
1079    }
1080
1081    #[test]
1082    fn harness_crypto_sha256_is_pure_for_handoff_effects() {
1083        let source = r#"fn main(harness: Harness) { harness.crypto.sha256("hello") }"#;
1084        let effects = compute_handoff_effects(source, None);
1085        assert!(effects.is_empty(), "expected no effects, got {effects:?}");
1086    }
1087
1088    #[test]
1089    fn harness_stdio_read_line_yields_stdio_read_effect() {
1090        let source = r"fn main(harness: Harness) { harness.stdio.read_line() }";
1091        let effects = compute_handoff_effects(source, None);
1092        assert!(
1093            effects
1094                .iter()
1095                .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1096                    && effect.scope == EffectScope::Read),
1097            "expected Stdio read effect, got {effects:?}"
1098        );
1099    }
1100
1101    #[test]
1102    fn llm_call_emits_llm_effect_with_provider_and_model() {
1103        let source = r#"fn main() {
1104            llm_call("summarize", { provider: "anthropic", model: "claude-3-5-sonnet" })
1105        }"#;
1106        let effects = compute_handoff_effects(source, None);
1107        let llm = effects
1108            .iter()
1109            .find(|effect| matches!(effect.kind, EffectKind::Llm { .. }))
1110            .expect("llm effect");
1111        let EffectKind::Llm { provider, model } = &llm.kind else {
1112            panic!("expected llm kind, got {:?}", llm.kind);
1113        };
1114        assert_eq!(provider.as_deref(), Some("anthropic"));
1115        assert_eq!(model.as_deref(), Some("claude-3-5-sonnet"));
1116    }
1117
1118    #[test]
1119    fn harness_llm_catalog_yields_read_effect() {
1120        let source = r"fn main(harness: Harness) {
1121            harness.llm.catalog()
1122            harness.llm.providers()
1123        }";
1124        let effects = compute_handoff_effects(source, None);
1125        assert!(
1126            effects
1127                .iter()
1128                .any(|effect| matches!(effect.kind, EffectKind::Llm { .. })
1129                    && effect.scope == EffectScope::Read),
1130            "expected LLM read effect, got {effects:?}"
1131        );
1132    }
1133
1134    #[test]
1135    fn ceiling_drops_disallowed_capabilities() {
1136        let source = r#"fn main(harness: Harness) {
1137            harness.net.get("https://example.test")
1138            harness.fs.read_file("/tmp/in")
1139        }"#;
1140        let mut ceiling = CapabilityPolicy::default();
1141        ceiling
1142            .capabilities
1143            .insert("workspace".to_string(), vec!["read_text".to_string()]);
1144        let effects = compute_handoff_effects(source, Some(&ceiling));
1145        assert!(
1146            effects
1147                .iter()
1148                .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1149            "ceiling without `network` should drop Net effect, got {effects:?}"
1150        );
1151        assert!(
1152            effects
1153                .iter()
1154                .any(|effect| matches!(effect.kind, EffectKind::Fs)),
1155            "ceiling with workspace.read_text should keep Fs read, got {effects:?}"
1156        );
1157    }
1158
1159    #[test]
1160    fn ceiling_side_effect_level_clamps_writes() {
1161        let source = r#"fn main(harness: Harness) {
1162            harness.net.get("https://example.test")
1163            __io_println("hi")
1164        }"#;
1165        let ceiling = CapabilityPolicy {
1166            side_effect_level: Some("read_only".to_string()),
1167            ..Default::default()
1168        };
1169        let effects = compute_handoff_effects(source, Some(&ceiling));
1170        assert!(
1171            effects
1172                .iter()
1173                .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1174            "read_only ceiling must drop Net write, got {effects:?}"
1175        );
1176        assert!(
1177            effects
1178                .iter()
1179                .any(|effect| matches!(effect.kind, EffectKind::Stdio)),
1180            "stdio observe should pass read_only ceiling, got {effects:?}"
1181        );
1182    }
1183
1184    #[test]
1185    fn effect_record_round_trips_through_serde() {
1186        let effects = vec![
1187            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1188                .with_resource("https://api.example/v1"),
1189            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1190            EffectRecord::new(
1191                EffectKind::Llm {
1192                    provider: Some("anthropic".to_string()),
1193                    model: Some("claude-3-7-sonnet".to_string()),
1194                },
1195                EffectScope::Write,
1196            ),
1197            EffectRecord::new(
1198                EffectKind::Tool {
1199                    name: "search".to_string(),
1200                },
1201                EffectScope::Read,
1202            ),
1203        ];
1204        let encoded = serde_json::to_string(&effects).expect("encode");
1205        let decoded: Vec<EffectRecord> = serde_json::from_str(&encoded).expect("decode");
1206        assert_eq!(decoded, effects);
1207    }
1208
1209    #[test]
1210    fn empty_source_returns_no_effects() {
1211        let effects = compute_handoff_effects("fn main() {}", None);
1212        assert!(effects.is_empty(), "got {effects:?}");
1213    }
1214
1215    #[test]
1216    fn effects_from_metadata_round_trips_typed_payload() {
1217        let effects = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1218            .with_resource("https://api.example")];
1219        let mut metadata: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1220        metadata.insert(
1221            "effects".to_string(),
1222            serde_json::to_value(&effects).expect("encode"),
1223        );
1224        assert_eq!(effects_from_metadata(&metadata), effects);
1225    }
1226
1227    #[test]
1228    fn subset_violations_returns_empty_when_child_covered() {
1229        let parent = vec![
1230            EffectRecord::new(EffectKind::Net, EffectScope::Write),
1231            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1232        ];
1233        let child = vec![
1234            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1235                .with_resource("https://example.test"),
1236            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1237        ];
1238        assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1239    }
1240
1241    #[test]
1242    fn subset_violations_flags_unmatched_kinds() {
1243        let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1244        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1245            .with_resource("https://example.test")];
1246        let violations = effect_subset_violations(Some(&parent), &child);
1247        assert_eq!(violations.len(), 1);
1248        assert!(matches!(violations[0].kind, EffectKind::Net));
1249    }
1250
1251    #[test]
1252    fn subset_violations_flags_scope_escalations() {
1253        let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1254        let child = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)];
1255        let violations = effect_subset_violations(Some(&parent), &child);
1256        assert_eq!(violations.len(), 1);
1257        assert_eq!(violations[0].scope, EffectScope::Mutate);
1258    }
1259
1260    #[test]
1261    fn subset_violations_treats_missing_parent_resource_as_wildcard() {
1262        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1263        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1264            .with_resource("https://api.example/v1")];
1265        assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1266    }
1267
1268    #[test]
1269    fn subset_violations_requires_resource_match_when_parent_declares_one() {
1270        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1271            .with_resource("https://allowed.test")];
1272        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1273            .with_resource("https://disallowed.test")];
1274        let violations = effect_subset_violations(Some(&parent), &child);
1275        assert_eq!(violations.len(), 1);
1276    }
1277
1278    #[test]
1279    fn subset_violations_skip_when_parent_is_none() {
1280        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1281        assert!(effect_subset_violations(None, &child).is_empty());
1282    }
1283
1284    #[test]
1285    fn subset_violations_empty_parent_flags_every_child_effect() {
1286        let parent: Vec<EffectRecord> = Vec::new();
1287        let child = vec![
1288            EffectRecord::new(EffectKind::Net, EffectScope::Write),
1289            EffectRecord::new(EffectKind::Fs, EffectScope::Read),
1290        ];
1291        let violations = effect_subset_violations(Some(&parent), &child);
1292        assert_eq!(violations.len(), 2);
1293    }
1294
1295    #[test]
1296    fn subset_violations_empty_child_is_always_allowed() {
1297        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1298        assert!(effect_subset_violations(Some(&parent), &[]).is_empty());
1299    }
1300
1301    #[test]
1302    fn effect_kind_label_shape() {
1303        assert_eq!(effect_kind_label(&EffectKind::Net), "net");
1304        assert_eq!(
1305            effect_kind_label(&EffectKind::Llm {
1306                provider: Some("anthropic".to_string()),
1307                model: Some("claude-3-7-sonnet".to_string()),
1308            }),
1309            "llm:anthropic/claude-3-7-sonnet"
1310        );
1311        assert_eq!(
1312            effect_kind_label(&EffectKind::Tool {
1313                name: "search".to_string()
1314            }),
1315            "tool:search"
1316        );
1317    }
1318
1319    #[test]
1320    fn effect_record_summary_includes_resource() {
1321        let effect = EffectRecord::new(EffectKind::Net, EffectScope::Write)
1322            .with_resource("https://example.test/api");
1323        assert_eq!(
1324            effect_record_summary(&effect),
1325            "net:write (https://example.test/api)"
1326        );
1327    }
1328
1329    #[test]
1330    fn deduplicates_repeated_effects() {
1331        let source = r#"fn main() {
1332            http_get("https://example.test")
1333            http_get("https://example.test")
1334            http_get("https://example.test")
1335        }"#;
1336        let effects = compute_handoff_effects(source, None);
1337        let net_count = effects
1338            .iter()
1339            .filter(|effect| matches!(effect.kind, EffectKind::Net))
1340            .count();
1341        assert_eq!(net_count, 1, "expected dedup, got {effects:?}");
1342    }
1343}