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