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