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        | "package_snapshot_open"
213        | "render"
214        | "render_prompt"
215        | "render_with_provenance"
216        | "find_text"
217        | "read_lines"
218        | "list_dir"
219        | "walk_dir"
220        | "glob"
221        | "file_exists"
222        | "path_status"
223        | "stat" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Read)),
224
225        // fs writes
226        "write_file"
227        | "write_file_bytes"
228        | "append_file"
229        | "append_file_locked"
230        | "mkdir"
231        | "mkdtemp"
232        | "mkdtemp_in_workspace"
233        | "copy_file"
234        | "move_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Write)),
235        "delete_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
236        "apply_edit" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
237
238        // network — mirrors `is_network_call` in harn-ir; the EffectKind
239        // is identical for every transport because the dispatcher (E5.4)
240        // enforces the ⊆ relation at the `Net` granularity, not per-verb.
241        "http_get"
242        | "http_post"
243        | "http_put"
244        | "http_patch"
245        | "http_delete"
246        | "http_request"
247        | "http_download"
248        | "http_session"
249        | "http_session_request"
250        | "http_session_close"
251        | "http_stream_open"
252        | "http_stream_read"
253        | "http_stream_close"
254        | "sse_connect"
255        | "sse_receive"
256        | "sse_close"
257        | "sse_server_response"
258        | "sse_server_send"
259        | "sse_server_heartbeat"
260        | "sse_server_flush"
261        | "sse_server_close"
262        | "sse_server_cancel"
263        | "websocket_connect"
264        | "websocket_accept"
265        | "websocket_send"
266        | "websocket_receive"
267        | "websocket_close"
268        | "websocket_route"
269        | "websocket_server"
270        | "websocket_server_close"
271        | "unix_socket_json_request"
272        | "__net_unix_socket_json_request" => {
273            Some(EffectRecord::new(EffectKind::Net, EffectScope::Write))
274        }
275
276        // llm
277        "llm_call"
278        | "llm_call_safe"
279        | "llm_stream_call"
280        | "llm_call_structured"
281        | "llm_call_structured_safe"
282        | "llm_call_structured_result"
283        | "llm_completion"
284        | "agent_llm_turn"
285        | "agent_turn"
286        | "agent_loop" => Some(EffectRecord::new(
287            EffectKind::Llm {
288                provider: None,
289                model: None,
290            },
291            EffectScope::Write,
292        )),
293        "llm_catalog" | "llm_provider_status" => Some(EffectRecord::new(
294            EffectKind::Llm {
295                provider: None,
296                model: None,
297            },
298            EffectScope::Read,
299        )),
300        "llm_catalog_refresh" => Some(EffectRecord::new(
301            EffectKind::Llm {
302                provider: None,
303                model: None,
304            },
305            EffectScope::Write,
306        )),
307
308        // spawn / worker dispatch
309        "spawn_agent"
310        | "send_input"
311        | "resume_agent"
312        | "wait_agent"
313        | "close_agent"
314        | "worker_trigger"
315        | "__host_sub_agent_run"
316        | "__host_worker_spawn"
317        | "__host_worker_send_input"
318        | "__host_worker_resume"
319        | "__host_worker_trigger"
320        | "__host_worker_wait"
321        | "__host_worker_close" => Some(EffectRecord::new(EffectKind::Spawn, EffectScope::Write)),
322
323        // pipeline-declared tools dispatched through tool_call
324        "tool_call" | "host_tool_call" => Some(EffectRecord::new(
325            EffectKind::Tool {
326                name: String::new(),
327            },
328            EffectScope::Write,
329        )),
330
331        _ => None,
332    }
333}
334
335fn annotate_with_resource(mut effect: EffectRecord, call: &harn_ir::CallSemantics) -> EffectRecord {
336    // Effect resources are best-effort: first literal arg (path / url /
337    // tool name) when statically derivable. Unknown literals stay
338    // unannotated rather than guessed.
339    match &mut effect.kind {
340        EffectKind::Llm { provider, model } => {
341            for arg in &call.literal_args {
342                if let LiteralValue::Dict(entries) = arg {
343                    if let Some(value) = entries.get("provider").and_then(literal_as_str) {
344                        *provider = Some(value.to_string());
345                    }
346                    if let Some(value) = entries.get("model").and_then(literal_as_str) {
347                        *model = Some(value.to_string());
348                    }
349                }
350            }
351        }
352        EffectKind::Tool { name } => {
353            if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
354                *name = value.to_string();
355            }
356        }
357        _ => {
358            if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
359                effect.resource = Some(value.to_string());
360            }
361        }
362    }
363    effect
364}
365
366fn capability_effect_to_record(effect: &harn_ir::CapabilityEffect) -> Option<EffectRecord> {
367    let (kind, scope) = match effect.capability {
368        Capability::WorkspaceMutation => (EffectKind::Fs, EffectScope::Mutate),
369        Capability::CommandExecution => (
370            EffectKind::Hostcall {
371                name: format!("process.{}", effect.operation),
372            },
373            EffectScope::Write,
374        ),
375        Capability::NetworkAccess => (EffectKind::Net, EffectScope::Write),
376        Capability::ConnectorAccess => (
377            EffectKind::Hostcall {
378                name: if effect.operation.is_empty() {
379                    "connector.call".to_string()
380                } else {
381                    format!("connector.{}", effect.operation)
382                },
383            },
384            EffectScope::Write,
385        ),
386        Capability::ModelCall => (
387            EffectKind::Llm {
388                provider: None,
389                model: None,
390            },
391            EffectScope::Write,
392        ),
393        Capability::WorkerDispatch => (EffectKind::Spawn, EffectScope::Write),
394        Capability::HumanApproval => return None,
395        Capability::AutonomyPolicy => return None,
396    };
397    let resource = effect.path.clone();
398    Some(EffectRecord {
399        kind,
400        scope,
401        resource,
402    })
403}
404
405fn hostcall_scope(operation: &str) -> EffectScope {
406    match operation {
407        op if op.starts_with("workspace.read") || op.starts_with("workspace.list") => {
408            EffectScope::Read
409        }
410        op if op.starts_with("workspace.write") || op == "workspace.apply_edit" => {
411            EffectScope::Mutate
412        }
413        op if op.starts_with("process.") => EffectScope::Write,
414        _ => EffectScope::Write,
415    }
416}
417
418fn literal_as_str(value: &LiteralValue) -> Option<&str> {
419    match value {
420        LiteralValue::String(value) | LiteralValue::Identifier(value) => Some(value.as_str()),
421        _ => None,
422    }
423}
424
425fn walk_for_harness_effects(node: &SNode, out: &mut BTreeSet<EffectRecord>) {
426    if let Some(effect) = harness_method_effect(node) {
427        out.insert(effect);
428    }
429    for child in child_nodes(node) {
430        walk_for_harness_effects(child, out);
431    }
432}
433
434fn harness_method_effect(node: &SNode) -> Option<EffectRecord> {
435    let (object, method) = match &node.node {
436        Node::MethodCall { object, method, .. }
437        | Node::OptionalMethodCall { object, method, .. } => (object, method),
438        _ => return None,
439    };
440    let (sub_handle, root) = harness_sub_handle(object)?;
441    if !is_harness_root(root) {
442        return None;
443    }
444    let (kind, scope) = match (sub_handle.as_str(), method.as_str()) {
445        ("stdio", "print" | "println" | "eprint" | "eprintln") => {
446            (EffectKind::Stdio, EffectScope::Observe)
447        }
448        ("stdio", "read_line" | "prompt") => (EffectKind::Stdio, EffectScope::Read),
449        ("term", "width" | "height" | "read_password") => (EffectKind::Stdio, EffectScope::Read),
450        ("clock", _) => return None,
451        ("env", "set" | "unset") => (
452            EffectKind::Hostcall {
453                name: "env.set".to_string(),
454            },
455            EffectScope::Mutate,
456        ),
457        ("env", _) => (
458            EffectKind::Hostcall {
459                name: "env.get".to_string(),
460            },
461            EffectScope::Read,
462        ),
463        ("random", _) => return None,
464        (
465            "fs",
466            "read_file" | "read_text" | "read" | "exists" | "status" | "path_status" | "list_dir"
467            | "stat",
468        ) => (EffectKind::Fs, EffectScope::Read),
469        (
470            "fs",
471            "write_file"
472            | "write_text"
473            | "append_file"
474            | "append_file_locked"
475            | "mkdir"
476            | "mkdtemp"
477            | "mkdtemp_in_workspace"
478            | "copy_file",
479        ) => (EffectKind::Fs, EffectScope::Write),
480        ("fs", "delete_file" | "delete" | "remove") => (EffectKind::Fs, EffectScope::Mutate),
481        ("fs", _) => (EffectKind::Fs, EffectScope::Read),
482        ("net", _) => (EffectKind::Net, EffectScope::Write),
483        ("process", "spawn_captured") => (
484            EffectKind::Hostcall {
485                name: "process.spawn_captured".to_string(),
486            },
487            EffectScope::Write,
488        ),
489        ("crypto", "sha256") => return None,
490        // System-introspection methods (`cpu`, `memory`, `gpus`,
491        // `temperature`, `platform`, `processes`) are pure host reads
492        // — no state mutation, no resource consumed. They're gated by
493        // the harness capability handle itself, so deny-by-default
494        // policies still block them, but they don't produce a typed
495        // effect record for child grant enforcement.
496        ("system", _) => return None,
497        ("llm", "catalog" | "providers") => (
498            EffectKind::Llm {
499                provider: None,
500                model: None,
501            },
502            EffectScope::Read,
503        ),
504        ("llm", _) => return None,
505        _ => return None,
506    };
507    Some(EffectRecord::new(kind, scope))
508}
509
510fn harness_sub_handle(node: &SNode) -> Option<(String, &SNode)> {
511    match &node.node {
512        Node::PropertyAccess { object, property }
513        | Node::OptionalPropertyAccess { object, property } => {
514            Some((property.clone(), object.as_ref()))
515        }
516        _ => None,
517    }
518}
519
520fn is_harness_root(node: &SNode) -> bool {
521    matches!(&node.node, Node::Identifier(name) if name == "harness")
522}
523
524fn child_nodes(node: &SNode) -> Vec<&SNode> {
525    let mut children: Vec<&SNode> = Vec::new();
526    match &node.node {
527        Node::AttributedDecl { inner, .. } => children.push(inner.as_ref()),
528        Node::Pipeline { body, .. }
529        | Node::FnDecl { body, .. }
530        | Node::ToolDecl { body, .. }
531        | Node::SpawnExpr { body }
532        | Node::Retry { body, .. }
533        | Node::TryExpr { body }
534        | Node::DeferStmt { body }
535        | Node::Block(body)
536        | Node::OverrideDecl { body, .. } => children.extend(body.iter()),
537        Node::MutexBlock { key, body } => {
538            children.extend(key.as_deref());
539            children.extend(body.iter());
540        }
541        Node::ImplBlock { methods, .. } => children.extend(methods.iter()),
542        Node::IfElse {
543            condition,
544            then_body,
545            else_body,
546        } => {
547            children.push(condition.as_ref());
548            children.extend(then_body.iter());
549            if let Some(else_body) = else_body.as_ref() {
550                children.extend(else_body.iter());
551            }
552        }
553        Node::ForIn { iterable, body, .. } => {
554            children.push(iterable.as_ref());
555            children.extend(body.iter());
556        }
557        Node::WhileLoop { condition, body } => {
558            children.push(condition.as_ref());
559            children.extend(body.iter());
560        }
561        Node::MatchExpr { value, arms } => {
562            children.push(value.as_ref());
563            for arm in arms {
564                if let Some(guard) = arm.guard.as_ref() {
565                    children.push(guard.as_ref());
566                }
567                children.extend(arm.body.iter());
568            }
569        }
570        Node::CostRoute { options, body } => {
571            for (_key, value) in options {
572                children.push(value);
573            }
574            children.extend(body.iter());
575        }
576        Node::ReturnStmt { value } => {
577            if let Some(value) = value.as_ref() {
578                children.push(value.as_ref());
579            }
580        }
581        Node::ThrowStmt { value } => children.push(value.as_ref()),
582        Node::TryCatch {
583            body,
584            catch_body,
585            finally_body,
586            ..
587        } => {
588            children.extend(body.iter());
589            children.extend(catch_body.iter());
590            if let Some(finally_body) = finally_body.as_ref() {
591                children.extend(finally_body.iter());
592            }
593        }
594        Node::SkillDecl { fields, .. } => {
595            for (_name, value) in fields {
596                children.push(value);
597            }
598        }
599        Node::EvalPackDecl {
600            fields,
601            body,
602            summarize,
603            ..
604        } => {
605            for (_name, value) in fields {
606                children.push(value);
607            }
608            children.extend(body.iter());
609            if let Some(summarize) = summarize.as_ref() {
610                children.extend(summarize.iter());
611            }
612        }
613        Node::LetBinding { value, .. } | Node::ConstBinding { value, .. } => {
614            children.push(value.as_ref());
615        }
616        Node::DeadlineBlock { duration, body } => {
617            children.push(duration.as_ref());
618            children.extend(body.iter());
619        }
620        Node::YieldExpr { value } => {
621            if let Some(value) = value.as_ref() {
622                children.push(value.as_ref());
623            }
624        }
625        Node::EmitExpr { value } => children.push(value.as_ref()),
626        Node::GuardStmt {
627            condition,
628            else_body,
629        } => {
630            children.push(condition.as_ref());
631            children.extend(else_body.iter());
632        }
633        Node::RequireStmt { condition, message } => {
634            children.push(condition.as_ref());
635            if let Some(message) = message.as_ref() {
636                children.push(message.as_ref());
637            }
638        }
639        Node::HitlExpr { args, .. } => {
640            for arg in args {
641                children.push(&arg.value);
642            }
643        }
644        Node::Parallel {
645            expr,
646            body,
647            options,
648            ..
649        } => {
650            children.push(expr.as_ref());
651            children.extend(body.iter());
652            for (_key, value) in options {
653                children.push(value);
654            }
655        }
656        Node::SelectExpr {
657            cases,
658            timeout,
659            default_body,
660        } => {
661            for case in cases {
662                children.push(case.channel.as_ref());
663                children.extend(case.body.iter());
664            }
665            if let Some((duration, body)) = timeout.as_ref() {
666                children.push(duration.as_ref());
667                children.extend(body.iter());
668            }
669            if let Some(body) = default_body.as_ref() {
670                children.extend(body.iter());
671            }
672        }
673        Node::FunctionCall { args, .. } => children.extend(args.iter()),
674        Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
675            children.push(object.as_ref());
676            children.extend(args.iter());
677        }
678        Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
679            children.push(object.as_ref());
680        }
681        Node::SubscriptAccess { object, index }
682        | Node::OptionalSubscriptAccess { object, index } => {
683            children.push(object.as_ref());
684            children.push(index.as_ref());
685        }
686        Node::SliceAccess { object, start, end } => {
687            children.push(object.as_ref());
688            if let Some(start) = start.as_ref() {
689                children.push(start.as_ref());
690            }
691            if let Some(end) = end.as_ref() {
692                children.push(end.as_ref());
693            }
694        }
695        Node::BinaryOp { left, right, .. } => {
696            children.push(left.as_ref());
697            children.push(right.as_ref());
698        }
699        Node::UnaryOp { operand, .. } => children.push(operand.as_ref()),
700        Node::Ternary {
701            condition,
702            true_expr,
703            false_expr,
704        } => {
705            children.push(condition.as_ref());
706            children.push(true_expr.as_ref());
707            children.push(false_expr.as_ref());
708        }
709        Node::Assignment { target, value, .. } => {
710            children.push(target.as_ref());
711            children.push(value.as_ref());
712        }
713        Node::EnumConstruct { args, .. } => children.extend(args.iter()),
714        Node::StructConstruct { fields, .. } => {
715            for entry in fields {
716                children.push(&entry.key);
717                children.push(&entry.value);
718            }
719        }
720        Node::ListLiteral(items) => children.extend(items.iter()),
721        Node::DictLiteral(entries) => {
722            for entry in entries {
723                children.push(&entry.key);
724                children.push(&entry.value);
725            }
726        }
727        Node::Spread(inner) => children.push(inner.as_ref()),
728        Node::TryOperator { operand } | Node::TryStar { operand } => {
729            children.push(operand.as_ref());
730        }
731        Node::OrPattern(items) => children.extend(items.iter()),
732        Node::Closure { body, .. } => children.extend(body.iter()),
733        Node::RangeExpr { start, end, .. } => {
734            children.push(start.as_ref());
735            children.push(end.as_ref());
736        }
737        _ => {}
738    }
739    children
740}
741
742fn effect_allowed_by_ceiling(effect: &EffectRecord, ceiling: &CapabilityPolicy) -> bool {
743    if !ceiling.capabilities.is_empty() {
744        let (capability, op) = effect_capability_op(effect);
745        let allowed = ceiling
746            .capabilities
747            .get(capability)
748            .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op));
749        if !allowed {
750            return false;
751        }
752    }
753    if let Some(ceiling_level) = ceiling.side_effect_level.as_deref() {
754        let requested = side_effect_level_for(effect);
755        if requested_exceeds_ceiling(requested, ceiling_level) {
756            return false;
757        }
758    }
759    true
760}
761
762fn effect_capability_op(effect: &EffectRecord) -> (&'static str, &'static str) {
763    match (&effect.kind, effect.scope) {
764        (EffectKind::Stdio, EffectScope::Read) => ("stdio", "read"),
765        (EffectKind::Stdio, _) => ("stdio", "write"),
766        (EffectKind::Fs, EffectScope::Read) => ("workspace", "read_text"),
767        (EffectKind::Fs, EffectScope::Write) => ("workspace", "write_text"),
768        (EffectKind::Fs, EffectScope::Mutate) => ("workspace", "apply_edit"),
769        (EffectKind::Fs, EffectScope::Observe) => ("workspace", "exists"),
770        (EffectKind::Net, _) => ("network", "http"),
771        (EffectKind::Llm { .. }, EffectScope::Read) => ("llm", "catalog"),
772        (EffectKind::Llm { .. }, _) => ("llm", "call"),
773        (EffectKind::Tool { .. }, _) => ("host", "tool_call"),
774        (EffectKind::Hostcall { .. }, _) => ("connector", "call"),
775        (EffectKind::Persona { .. }, _) => ("worker", "dispatch"),
776        (EffectKind::Spawn, _) => ("worker", "dispatch"),
777    }
778}
779
780fn side_effect_level_for(effect: &EffectRecord) -> &'static str {
781    match (&effect.kind, effect.scope) {
782        (EffectKind::Stdio, _) => "read_only",
783        (EffectKind::Fs, EffectScope::Read | EffectScope::Observe) => "read_only",
784        (EffectKind::Fs, _) => "workspace_write",
785        (EffectKind::Net, _) => "network",
786        (EffectKind::Llm { .. }, EffectScope::Read) => "read_only",
787        (EffectKind::Llm { .. }, _) => "network",
788        (EffectKind::Tool { .. }, _) => "workspace_write",
789        (EffectKind::Hostcall { name }, _) if name.starts_with("process.") => "process_exec",
790        (EffectKind::Hostcall { .. }, _) => "read_only",
791        (EffectKind::Persona { .. }, _) => "workspace_write",
792        (EffectKind::Spawn, _) => "workspace_write",
793    }
794}
795
796fn requested_exceeds_ceiling(requested: &str, ceiling: &str) -> bool {
797    fn rank(value: &str) -> usize {
798        crate::tool_annotations::SideEffectLevel::rank_str(value)
799    }
800    rank(requested) > rank(ceiling)
801}
802
803/// Round-trip a typed effect list through the `metadata` map a child
804/// spawn-config carries. Pipelines that pre-compute effects can stash
805/// them under `effects` and the spawn shim lifts them onto the handoff.
806pub fn effects_from_metadata(metadata: &BTreeMap<String, serde_json::Value>) -> Vec<EffectRecord> {
807    metadata
808        .get("effects")
809        .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
810        .unwrap_or_default()
811}
812
813/// Decide whether `child` is covered by `parent`. An effect is covered
814/// when the parent declares another record with the same kind family
815/// and a scope that is at least as permissive. `resource` is treated
816/// best-effort: when the parent carries a non-empty resource it must
817/// match the child's resource exactly (and the child's resource must be
818/// known); when the parent has no resource it covers any resource the
819/// child names. This is the core of E5.4's `HARN-CAP-301` enforcement —
820/// the dispatcher and the static analyzer share one implementation so
821/// preflight and runtime never disagree.
822fn parent_covers_child(parent: &EffectRecord, child: &EffectRecord) -> bool {
823    if !effect_kind_family_matches(&parent.kind, &child.kind) {
824        return false;
825    }
826    if !effect_scope_covers(parent.scope, child.scope) {
827        return false;
828    }
829    match (parent.resource.as_deref(), child.resource.as_deref()) {
830        (Some(""), _) => true,
831        (Some(parent_resource), Some(child_resource)) => parent_resource == child_resource,
832        (Some(_), None) => false,
833        (None, _) => true,
834    }
835}
836
837fn effect_kind_family_matches(parent: &EffectKind, child: &EffectKind) -> bool {
838    match (parent, child) {
839        (EffectKind::Stdio, EffectKind::Stdio)
840        | (EffectKind::Fs, EffectKind::Fs)
841        | (EffectKind::Net, EffectKind::Net)
842        | (EffectKind::Spawn, EffectKind::Spawn) => true,
843        (EffectKind::Llm { .. }, EffectKind::Llm { .. }) => true,
844        (
845            EffectKind::Tool {
846                name: parent_name, ..
847            },
848            EffectKind::Tool {
849                name: child_name, ..
850            },
851        ) => parent_name.is_empty() || parent_name == child_name,
852        (
853            EffectKind::Hostcall {
854                name: parent_name, ..
855            },
856            EffectKind::Hostcall {
857                name: child_name, ..
858            },
859        ) => parent_name.is_empty() || parent_name == child_name,
860        (EffectKind::Persona { id: parent_id }, EffectKind::Persona { id: child_id }) => {
861            parent_id.is_empty() || parent_id == child_id
862        }
863        _ => false,
864    }
865}
866
867fn effect_scope_covers(parent: EffectScope, child: EffectScope) -> bool {
868    fn rank(scope: EffectScope) -> u8 {
869        match scope {
870            EffectScope::Read => 1,
871            EffectScope::Observe => 1,
872            EffectScope::Write => 2,
873            EffectScope::Mutate => 3,
874        }
875    }
876    rank(parent) >= rank(child)
877}
878
879/// Compute the subset of `child` effects that are not covered by any
880/// record in `parent`. An empty parent set is treated as "no declared
881/// effects" — under E5.4 the dispatcher takes that to mean every child
882/// effect is a violation, because a child can never out-grant an
883/// undeclared parent. When `parent` is `None` enforcement is skipped
884/// entirely (the caller has decided no static ceiling applies).
885pub fn effect_subset_violations(
886    parent: Option<&[EffectRecord]>,
887    child: &[EffectRecord],
888) -> Vec<EffectRecord> {
889    let Some(parent) = parent else {
890        return Vec::new();
891    };
892    child
893        .iter()
894        .filter(|effect| {
895            !parent
896                .iter()
897                .any(|allowed| parent_covers_child(allowed, effect))
898        })
899        .cloned()
900        .collect()
901}
902
903/// Short human-readable label for `effect.kind` used in
904/// `EffectInheritanceViolation` messages and `HARN-CAP-301` diagnostics.
905pub fn effect_kind_label(kind: &EffectKind) -> String {
906    match kind {
907        EffectKind::Stdio => "stdio".to_string(),
908        EffectKind::Fs => "fs".to_string(),
909        EffectKind::Net => "net".to_string(),
910        EffectKind::Llm { provider, model } => match (provider.as_deref(), model.as_deref()) {
911            (Some(provider), Some(model)) => format!("llm:{provider}/{model}"),
912            (Some(provider), None) => format!("llm:{provider}"),
913            (None, Some(model)) => format!("llm:{model}"),
914            (None, None) => "llm".to_string(),
915        },
916        EffectKind::Tool { name } if !name.is_empty() => format!("tool:{name}"),
917        EffectKind::Tool { .. } => "tool".to_string(),
918        EffectKind::Hostcall { name } if !name.is_empty() => format!("hostcall:{name}"),
919        EffectKind::Hostcall { .. } => "hostcall".to_string(),
920        EffectKind::Persona { id } if !id.is_empty() => format!("persona:{id}"),
921        EffectKind::Persona { .. } => "persona".to_string(),
922        EffectKind::Spawn => "spawn".to_string(),
923    }
924}
925
926/// One-line summary suitable for diagnostic messages and deny events.
927pub fn effect_record_summary(effect: &EffectRecord) -> String {
928    let scope = match effect.scope {
929        EffectScope::Read => "read",
930        EffectScope::Write => "write",
931        EffectScope::Mutate => "mutate",
932        EffectScope::Observe => "observe",
933    };
934    match effect.resource.as_deref() {
935        Some(resource) if !resource.is_empty() => {
936            format!(
937                "{}:{} ({})",
938                effect_kind_label(&effect.kind),
939                scope,
940                resource
941            )
942        }
943        _ => format!("{}:{}", effect_kind_label(&effect.kind), scope),
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    #[test]
952    fn harness_net_call_yields_net_effect() {
953        let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test") }"#;
954        let effects = compute_handoff_effects(source, None);
955        assert!(
956            effects
957                .iter()
958                .any(|effect| matches!(effect.kind, EffectKind::Net)
959                    && effect.scope == EffectScope::Write),
960            "expected Net write effect, got {effects:?}"
961        );
962    }
963
964    #[test]
965    fn harness_process_spawn_captured_yields_process_hostcall_effect() {
966        let source =
967            r#"fn main(harness: Harness) { harness.process.spawn_captured({cmd: "printf"}) }"#;
968        let effects = compute_handoff_effects(source, None);
969        assert!(
970            effects.iter().any(|effect| {
971                matches!(
972                    &effect.kind,
973                    EffectKind::Hostcall { name } if name == "process.spawn_captured"
974                ) && effect.scope == EffectScope::Write
975            }),
976            "expected process hostcall write effect, got {effects:?}"
977        );
978    }
979
980    #[test]
981    fn http_get_builtin_yields_net_effect_with_resource() {
982        let source = r#"fn main() { http_get("https://example.test/api") }"#;
983        let effects = compute_handoff_effects(source, None);
984        let net = effects
985            .iter()
986            .find(|effect| matches!(effect.kind, EffectKind::Net))
987            .expect("net effect");
988        assert_eq!(net.scope, EffectScope::Write);
989        assert_eq!(net.resource.as_deref(), Some("https://example.test/api"));
990    }
991
992    #[test]
993    fn unix_socket_json_request_yields_net_effect_with_resource() {
994        let source = r#"fn main() { __net_unix_socket_json_request("/tmp/harn.sock", {}) }"#;
995        let effects = compute_handoff_effects(source, None);
996        let net = effects
997            .iter()
998            .find(|effect| matches!(effect.kind, EffectKind::Net))
999            .expect("net effect");
1000        assert_eq!(net.scope, EffectScope::Write);
1001        assert_eq!(net.resource.as_deref(), Some("/tmp/harn.sock"));
1002    }
1003
1004    #[test]
1005    fn files_upload_yields_fs_read_and_net_write_effects() {
1006        let source = r#"fn main() { __files_upload("/tmp/input.pdf", "gemini") }"#;
1007        let effects = compute_handoff_effects(source, None);
1008        assert!(
1009            effects.iter().any(|effect| {
1010                matches!(effect.kind, EffectKind::Fs)
1011                    && effect.scope == EffectScope::Read
1012                    && effect.resource.as_deref() == Some("/tmp/input.pdf")
1013            }),
1014            "expected Fs read effect, got {effects:?}"
1015        );
1016        assert!(
1017            effects.iter().any(|effect| {
1018                matches!(effect.kind, EffectKind::Net)
1019                    && effect.scope == EffectScope::Write
1020                    && effect.resource.as_deref() == Some("gemini")
1021            }),
1022            "expected Net write effect, got {effects:?}"
1023        );
1024    }
1025
1026    #[test]
1027    fn std_files_upload_wrapper_yields_fs_read_and_net_write_effects() {
1028        let source = r#"
1029import { upload } from "std/files"
1030fn main() { upload("/tmp/input.pdf", "gemini") }
1031"#;
1032        let effects = compute_handoff_effects(source, None);
1033        assert!(
1034            effects.iter().any(|effect| {
1035                matches!(effect.kind, EffectKind::Fs)
1036                    && effect.scope == EffectScope::Read
1037                    && effect.resource.as_deref() == Some("/tmp/input.pdf")
1038            }),
1039            "expected Fs read effect, got {effects:?}"
1040        );
1041        assert!(
1042            effects.iter().any(|effect| {
1043                matches!(effect.kind, EffectKind::Net)
1044                    && effect.scope == EffectScope::Write
1045                    && effect.resource.as_deref() == Some("gemini")
1046            }),
1047            "expected Net write effect, got {effects:?}"
1048        );
1049    }
1050
1051    #[test]
1052    fn harness_fs_write_yields_fs_write_effect() {
1053        let source = r#"fn main(harness: Harness) { harness.fs.write_file("/tmp/out", "hi") }"#;
1054        let effects = compute_handoff_effects(source, None);
1055        assert!(
1056            effects
1057                .iter()
1058                .any(|effect| matches!(effect.kind, EffectKind::Fs)
1059                    && effect.scope == EffectScope::Write),
1060            "expected Fs write effect, got {effects:?}"
1061        );
1062    }
1063
1064    #[test]
1065    fn harness_term_read_password_yields_stdio_read_effect() {
1066        let source = r#"fn main(harness: Harness) { harness.term.read_password("password: ") }"#;
1067        let effects = compute_handoff_effects(source, None);
1068        assert!(
1069            effects
1070                .iter()
1071                .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1072                    && effect.scope == EffectScope::Read),
1073            "expected Stdio read effect, got {effects:?}"
1074        );
1075    }
1076
1077    #[test]
1078    fn harness_fs_mkdtemp_yields_fs_write_effect() {
1079        let source = r#"fn main(harness: Harness) { harness.fs.mkdtemp_in_workspace("harn-") }"#;
1080        let effects = compute_handoff_effects(source, None);
1081        assert!(
1082            effects
1083                .iter()
1084                .any(|effect| matches!(effect.kind, EffectKind::Fs)
1085                    && effect.scope == EffectScope::Write),
1086            "expected Fs write effect, got {effects:?}"
1087        );
1088    }
1089
1090    #[test]
1091    fn harness_crypto_sha256_is_pure_for_handoff_effects() {
1092        let source = r#"fn main(harness: Harness) { harness.crypto.sha256("hello") }"#;
1093        let effects = compute_handoff_effects(source, None);
1094        assert!(effects.is_empty(), "expected no effects, got {effects:?}");
1095    }
1096
1097    #[test]
1098    fn harness_stdio_read_line_yields_stdio_read_effect() {
1099        let source = r"fn main(harness: Harness) { harness.stdio.read_line() }";
1100        let effects = compute_handoff_effects(source, None);
1101        assert!(
1102            effects
1103                .iter()
1104                .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1105                    && effect.scope == EffectScope::Read),
1106            "expected Stdio read effect, got {effects:?}"
1107        );
1108    }
1109
1110    #[test]
1111    fn llm_call_emits_llm_effect_with_provider_and_model() {
1112        let source = r#"fn main() {
1113            llm_call("summarize", { provider: "anthropic", model: "claude-3-5-sonnet" })
1114        }"#;
1115        let effects = compute_handoff_effects(source, None);
1116        let llm = effects
1117            .iter()
1118            .find(|effect| matches!(effect.kind, EffectKind::Llm { .. }))
1119            .expect("llm effect");
1120        let EffectKind::Llm { provider, model } = &llm.kind else {
1121            panic!("expected llm kind, got {:?}", llm.kind);
1122        };
1123        assert_eq!(provider.as_deref(), Some("anthropic"));
1124        assert_eq!(model.as_deref(), Some("claude-3-5-sonnet"));
1125    }
1126
1127    #[test]
1128    fn harness_llm_catalog_yields_read_effect() {
1129        let source = r"fn main(harness: Harness) {
1130            harness.llm.catalog()
1131            harness.llm.providers()
1132        }";
1133        let effects = compute_handoff_effects(source, None);
1134        assert!(
1135            effects
1136                .iter()
1137                .any(|effect| matches!(effect.kind, EffectKind::Llm { .. })
1138                    && effect.scope == EffectScope::Read),
1139            "expected LLM read effect, got {effects:?}"
1140        );
1141    }
1142
1143    #[test]
1144    fn ceiling_drops_disallowed_capabilities() {
1145        let source = r#"fn main(harness: Harness) {
1146            harness.net.get("https://example.test")
1147            harness.fs.read_file("/tmp/in")
1148        }"#;
1149        let mut ceiling = CapabilityPolicy::default();
1150        ceiling
1151            .capabilities
1152            .insert("workspace".to_string(), vec!["read_text".to_string()]);
1153        let effects = compute_handoff_effects(source, Some(&ceiling));
1154        assert!(
1155            effects
1156                .iter()
1157                .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1158            "ceiling without `network` should drop Net effect, got {effects:?}"
1159        );
1160        assert!(
1161            effects
1162                .iter()
1163                .any(|effect| matches!(effect.kind, EffectKind::Fs)),
1164            "ceiling with workspace.read_text should keep Fs read, got {effects:?}"
1165        );
1166    }
1167
1168    #[test]
1169    fn ceiling_side_effect_level_clamps_writes() {
1170        let source = r#"fn main(harness: Harness) {
1171            harness.net.get("https://example.test")
1172            __io_println("hi")
1173        }"#;
1174        let ceiling = CapabilityPolicy {
1175            side_effect_level: Some("read_only".to_string()),
1176            ..Default::default()
1177        };
1178        let effects = compute_handoff_effects(source, Some(&ceiling));
1179        assert!(
1180            effects
1181                .iter()
1182                .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1183            "read_only ceiling must drop Net write, got {effects:?}"
1184        );
1185        assert!(
1186            effects
1187                .iter()
1188                .any(|effect| matches!(effect.kind, EffectKind::Stdio)),
1189            "stdio observe should pass read_only ceiling, got {effects:?}"
1190        );
1191    }
1192
1193    #[test]
1194    fn effect_record_round_trips_through_serde() {
1195        let effects = vec![
1196            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1197                .with_resource("https://api.example/v1"),
1198            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1199            EffectRecord::new(
1200                EffectKind::Llm {
1201                    provider: Some("anthropic".to_string()),
1202                    model: Some("claude-3-7-sonnet".to_string()),
1203                },
1204                EffectScope::Write,
1205            ),
1206            EffectRecord::new(
1207                EffectKind::Tool {
1208                    name: "search".to_string(),
1209                },
1210                EffectScope::Read,
1211            ),
1212        ];
1213        let encoded = serde_json::to_string(&effects).expect("encode");
1214        let decoded: Vec<EffectRecord> = serde_json::from_str(&encoded).expect("decode");
1215        assert_eq!(decoded, effects);
1216    }
1217
1218    #[test]
1219    fn empty_source_returns_no_effects() {
1220        let effects = compute_handoff_effects("fn main() {}", None);
1221        assert!(effects.is_empty(), "got {effects:?}");
1222    }
1223
1224    #[test]
1225    fn effects_from_metadata_round_trips_typed_payload() {
1226        let effects = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1227            .with_resource("https://api.example")];
1228        let mut metadata: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1229        metadata.insert(
1230            "effects".to_string(),
1231            serde_json::to_value(&effects).expect("encode"),
1232        );
1233        assert_eq!(effects_from_metadata(&metadata), effects);
1234    }
1235
1236    #[test]
1237    fn subset_violations_returns_empty_when_child_covered() {
1238        let parent = vec![
1239            EffectRecord::new(EffectKind::Net, EffectScope::Write),
1240            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1241        ];
1242        let child = vec![
1243            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1244                .with_resource("https://example.test"),
1245            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1246        ];
1247        assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1248    }
1249
1250    #[test]
1251    fn subset_violations_flags_unmatched_kinds() {
1252        let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1253        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1254            .with_resource("https://example.test")];
1255        let violations = effect_subset_violations(Some(&parent), &child);
1256        assert_eq!(violations.len(), 1);
1257        assert!(matches!(violations[0].kind, EffectKind::Net));
1258    }
1259
1260    #[test]
1261    fn subset_violations_flags_scope_escalations() {
1262        let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1263        let child = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)];
1264        let violations = effect_subset_violations(Some(&parent), &child);
1265        assert_eq!(violations.len(), 1);
1266        assert_eq!(violations[0].scope, EffectScope::Mutate);
1267    }
1268
1269    #[test]
1270    fn subset_violations_treats_missing_parent_resource_as_wildcard() {
1271        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1272        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1273            .with_resource("https://api.example/v1")];
1274        assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1275    }
1276
1277    #[test]
1278    fn subset_violations_requires_resource_match_when_parent_declares_one() {
1279        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1280            .with_resource("https://allowed.test")];
1281        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1282            .with_resource("https://disallowed.test")];
1283        let violations = effect_subset_violations(Some(&parent), &child);
1284        assert_eq!(violations.len(), 1);
1285    }
1286
1287    #[test]
1288    fn subset_violations_skip_when_parent_is_none() {
1289        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1290        assert!(effect_subset_violations(None, &child).is_empty());
1291    }
1292
1293    #[test]
1294    fn subset_violations_empty_parent_flags_every_child_effect() {
1295        let parent: Vec<EffectRecord> = Vec::new();
1296        let child = vec![
1297            EffectRecord::new(EffectKind::Net, EffectScope::Write),
1298            EffectRecord::new(EffectKind::Fs, EffectScope::Read),
1299        ];
1300        let violations = effect_subset_violations(Some(&parent), &child);
1301        assert_eq!(violations.len(), 2);
1302    }
1303
1304    #[test]
1305    fn subset_violations_empty_child_is_always_allowed() {
1306        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1307        assert!(effect_subset_violations(Some(&parent), &[]).is_empty());
1308    }
1309
1310    #[test]
1311    fn effect_kind_label_shape() {
1312        assert_eq!(effect_kind_label(&EffectKind::Net), "net");
1313        assert_eq!(
1314            effect_kind_label(&EffectKind::Llm {
1315                provider: Some("anthropic".to_string()),
1316                model: Some("claude-3-7-sonnet".to_string()),
1317            }),
1318            "llm:anthropic/claude-3-7-sonnet"
1319        );
1320        assert_eq!(
1321            effect_kind_label(&EffectKind::Tool {
1322                name: "search".to_string()
1323            }),
1324            "tool:search"
1325        );
1326    }
1327
1328    #[test]
1329    fn effect_record_summary_includes_resource() {
1330        let effect = EffectRecord::new(EffectKind::Net, EffectScope::Write)
1331            .with_resource("https://example.test/api");
1332        assert_eq!(
1333            effect_record_summary(&effect),
1334            "net:write (https://example.test/api)"
1335        );
1336    }
1337
1338    #[test]
1339    fn deduplicates_repeated_effects() {
1340        let source = r#"fn main() {
1341            http_get("https://example.test")
1342            http_get("https://example.test")
1343            http_get("https://example.test")
1344        }"#;
1345        let effects = compute_handoff_effects(source, None);
1346        let net_count = effects
1347            .iter()
1348            .filter(|effect| matches!(effect.kind, EffectKind::Net))
1349            .count();
1350        assert_eq!(net_count, 1, "expected dedup, got {effects:?}");
1351    }
1352}