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