Skip to main content

harn_vm/orchestration/policy/
effects.rs

1//! Typed effect records carried on `HandoffArtifact` envelopes.
2//!
3//! `EffectRecord` names one side-effect a spawned child may exercise. The set
4//! sits on each handoff so the
5//! dispatcher (E5.4) and the OpenTrustGraph receipt chain (E5.5) can prove
6//! the child never escaped its parent's effect grant.
7//! Computation at spawn time walks the child's entrypoint module via the
8//! same capability analysis `harn graph --json` uses (issue HARN-#1758),
9//! plus a conservative AST walker for harness calls embedded in inline spawn
10//! configs. The two extraction paths feed one canonicalization step so
11//! downstream consumers see a single deduped, deterministically ordered list.
12
13use std::borrow::Cow;
14use std::collections::{BTreeMap, BTreeSet, HashSet};
15
16use serde::{Deserialize, Serialize};
17
18use harn_ir::{CallClassification, Capability, LiteralValue, NodeSemantics};
19use harn_parser::{Node, SNode};
20
21use super::effect_call_cache::resolve_runtime_resources;
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    /// Opaque authority that public values cannot manufacture.
55    Authority,
56    /// LLM calls with known provider and model receipt metadata.
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::Authority => EffectKind::Authority,
302        ContractKind::Secret => EffectKind::Secret,
303        ContractKind::Observability => EffectKind::Observability,
304        ContractKind::Channel => EffectKind::Channel,
305        ContractKind::State => EffectKind::State,
306    }
307}
308
309fn effect_scope_from_contract(access: harn_builtin_meta::EffectAccess) -> EffectScope {
310    match access {
311        harn_builtin_meta::EffectAccess::Read => EffectScope::Read,
312        harn_builtin_meta::EffectAccess::Write => EffectScope::Write,
313        harn_builtin_meta::EffectAccess::Mutate => EffectScope::Mutate,
314        harn_builtin_meta::EffectAccess::Observe => EffectScope::Observe,
315    }
316}
317
318fn resolve_contract_resources(
319    selector: harn_builtin_meta::ResourceSelector,
320    args: &[LiteralValue],
321) -> Vec<String> {
322    use harn_builtin_meta::ResourceSelector;
323    match selector {
324        ResourceSelector::Argument(index) => args
325            .get(index as usize)
326            .and_then(LiteralValue::as_str)
327            .map(|value| vec![value.to_string()])
328            .unwrap_or_default(),
329        ResourceSelector::Field { argument, path } => {
330            let mut value = args.get(argument as usize);
331            for field in path {
332                value = value.and_then(|value| value.dict_field(field));
333            }
334            value
335                .and_then(LiteralValue::as_str)
336                .map(|value| vec![value.to_string()])
337                .unwrap_or_default()
338        }
339        ResourceSelector::EachArgument(index) => args
340            .get(index as usize)
341            .and_then(LiteralValue::list_items)
342            .into_iter()
343            .flatten()
344            .filter_map(LiteralValue::as_str)
345            .map(str::to_string)
346            .collect(),
347        ResourceSelector::Constant(value) => vec![value.to_string()],
348        ResourceSelector::Dynamic => Vec::new(),
349    }
350}
351
352fn effect_specs_to_records(
353    specs: &[harn_builtin_meta::EffectSpec],
354    args: &[LiteralValue],
355) -> Vec<EffectRecord> {
356    let mut records = Vec::new();
357    let llm_specs = specs
358        .iter()
359        .filter(|spec| spec.kind == harn_builtin_meta::EffectKind::Llm)
360        .collect::<Vec<_>>();
361    if let Some(first) = llm_specs.first() {
362        let mut provider = None;
363        let mut model = None;
364        for spec in &llm_specs {
365            for selector in spec.resources {
366                let harn_builtin_meta::ResourceSelector::Field { path, .. } = selector else {
367                    continue;
368                };
369                let value = resolve_contract_resources(*selector, args)
370                    .into_iter()
371                    .next();
372                match path.last().copied() {
373                    Some("provider") => provider = value,
374                    Some("model") => model = value,
375                    _ => {}
376                }
377            }
378        }
379        records.push(EffectRecord::new(
380            EffectKind::Llm { provider, model },
381            effect_scope_from_contract(first.access),
382        ));
383    }
384    for spec in specs {
385        if spec.kind == harn_builtin_meta::EffectKind::Llm {
386            continue;
387        }
388        let kind = effect_kind_from_contract(spec.kind);
389        let scope = effect_scope_from_contract(spec.access);
390        let resources = spec
391            .resources
392            .iter()
393            .flat_map(|selector| resolve_contract_resources(*selector, args))
394            .collect::<Vec<_>>();
395        if resources.is_empty() {
396            records.push(EffectRecord::new(kind, scope));
397        } else {
398            records.extend(
399                resources
400                    .into_iter()
401                    .map(|resource| EffectRecord::new(kind.clone(), scope).with_resource(resource)),
402            );
403        }
404    }
405    records
406}
407
408fn builtin_effect(name: &str) -> Option<EffectRecord> {
409    match name {
410        // stdio
411        "print" | "println" | "eprint" | "eprintln" | "write_stdout" | "write_stderr"
412        | "__io_print" | "__io_println" | "__io_eprint" | "__io_eprintln" | "__io_write_stdout"
413        | "__io_write_stderr" => Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Observe)),
414        "read_line" | "read_stdin" | "prompt_user" | "__io_read_line" => {
415            Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Read))
416        }
417
418        // fs reads
419        "read_file"
420        | "read_file_bytes"
421        | "read_file_result"
422        | "package_snapshot_open"
423        | "render"
424        | "render_prompt"
425        | "render_with_provenance"
426        | "find_text"
427        | "find_evidence"
428        | "read_lines"
429        | "list_dir"
430        | "walk_dir"
431        | "glob"
432        | "file_exists"
433        | "path_status"
434        | "stat" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Read)),
435
436        // fs writes
437        "write_file"
438        | "write_file_bytes"
439        | "replace_file"
440        | "replace_file_result"
441        | "replace_file_bytes"
442        | "replace_file_bytes_result"
443        | "append_file"
444        | "append_file_locked"
445        | "mkdir"
446        | "mkdtemp"
447        | "mkdtemp_in_workspace"
448        | "copy_file"
449        | "move_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Write)),
450        "delete_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
451        "apply_edit" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
452
453        // network — mirrors `is_network_call` in harn-ir; the EffectKind
454        // is identical for every transport because the dispatcher (E5.4)
455        // enforces the ⊆ relation at the `Net` granularity, not per-verb.
456        "http_get"
457        | "http_post"
458        | "http_put"
459        | "http_patch"
460        | "http_delete"
461        | "http_request"
462        | "http_download"
463        | "http_session"
464        | "http_session_request"
465        | "http_session_close"
466        | "http_stream_open"
467        | "http_stream_read"
468        | "http_stream_close"
469        | "http_stream_info"
470        | "sse_connect"
471        | "sse_receive"
472        | "sse_close"
473        | "sse_server_response"
474        | "sse_server_send"
475        | "sse_server_heartbeat"
476        | "sse_server_flush"
477        | "sse_server_close"
478        | "sse_server_cancel"
479        | "websocket_connect"
480        | "websocket_accept"
481        | "websocket_send"
482        | "websocket_receive"
483        | "websocket_close"
484        | "websocket_route"
485        | "websocket_server"
486        | "websocket_server_close"
487        | "unix_socket_json_request"
488        | "__net_unix_socket_json_request" => {
489            Some(EffectRecord::new(EffectKind::Net, EffectScope::Write))
490        }
491
492        // llm
493        "llm_call"
494        | "llm_call_safe"
495        | "llm_stream_call"
496        | "llm_call_structured"
497        | "llm_call_structured_safe"
498        | "llm_call_structured_result"
499        | "llm_completion"
500        | "agent_loop" => Some(EffectRecord::new(
501            EffectKind::Llm {
502                provider: None,
503                model: None,
504            },
505            EffectScope::Write,
506        )),
507        "llm_catalog" | "llm_provider_status" => Some(EffectRecord::new(
508            EffectKind::Llm {
509                provider: None,
510                model: None,
511            },
512            EffectScope::Read,
513        )),
514        "llm_catalog_refresh" => Some(EffectRecord::new(
515            EffectKind::Llm {
516                provider: None,
517                model: None,
518            },
519            EffectScope::Write,
520        )),
521
522        // spawn / worker dispatch
523        "spawn_agent"
524        | "send_input"
525        | "resume_agent"
526        | "wait_agent"
527        | "close_agent"
528        | "worker_trigger"
529        | "__host_sub_agent_run"
530        | "__host_worker_spawn"
531        | "__host_worker_send_input"
532        | "__host_worker_resume"
533        | "__host_worker_trigger"
534        | "__host_worker_wait"
535        | "__host_worker_close" => Some(EffectRecord::new(EffectKind::Spawn, EffectScope::Write)),
536
537        // pipeline-declared tools dispatched through tool_call
538        "tool_call" | "host_tool_call" => Some(EffectRecord::new(
539            EffectKind::Tool {
540                name: String::new(),
541            },
542            EffectScope::Write,
543        )),
544
545        _ => None,
546    }
547}
548
549pub(super) fn builtin_has_network_effect(name: &str) -> bool {
550    if matches!(name, "__files_upload" | "upload") {
551        return true;
552    }
553    builtin_effect(name).is_some_and(|effect| matches!(effect.kind, EffectKind::Net))
554}
555
556fn capability_effect_to_record(effect: &harn_ir::CapabilityEffect) -> Option<EffectRecord> {
557    let contract_scope = match effect.access {
558        harn_builtin_meta::EffectAccess::Read => EffectScope::Read,
559        harn_builtin_meta::EffectAccess::Write => EffectScope::Write,
560        harn_builtin_meta::EffectAccess::Mutate => EffectScope::Mutate,
561        harn_builtin_meta::EffectAccess::Observe => EffectScope::Observe,
562    };
563    let (kind, scope) = match effect.capability {
564        Capability::FilesystemRead => (EffectKind::Fs, contract_scope),
565        Capability::WorkspaceMutation => (EffectKind::Fs, EffectScope::Mutate),
566        Capability::CommandExecution => (
567            EffectKind::Hostcall {
568                name: format!("process.{}", effect.operation),
569            },
570            EffectScope::Write,
571        ),
572        Capability::NetworkAccess => (EffectKind::Net, contract_scope),
573        Capability::ConnectorAccess => (
574            EffectKind::Hostcall {
575                name: if effect.operation.is_empty() {
576                    "connector.call".to_string()
577                } else {
578                    format!("connector.{}", effect.operation)
579                },
580            },
581            EffectScope::Write,
582        ),
583        Capability::Authority => (EffectKind::Authority, contract_scope),
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.as_ref());
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, Cow<'static, str>) {
764    let fixed = |capability, operation| (capability, Cow::Borrowed(operation));
765    match (&effect.kind, effect.scope) {
766        (EffectKind::Stdio, EffectScope::Read) => fixed("stdio", "read"),
767        (EffectKind::Stdio, _) => fixed("stdio", "write"),
768        (EffectKind::Fs, EffectScope::Read) => fixed("workspace", "read_text"),
769        (EffectKind::Fs, EffectScope::Write) => fixed("workspace", "write_text"),
770        (EffectKind::Fs, EffectScope::Mutate) => fixed("workspace", "apply_edit"),
771        (EffectKind::Fs, EffectScope::Observe) => fixed("workspace", "exists"),
772        (EffectKind::Net, _) => fixed("network", "http"),
773        (EffectKind::Env, EffectScope::Read | EffectScope::Observe) => fixed("environment", "read"),
774        (EffectKind::Env, _) => fixed("environment", "write"),
775        (EffectKind::Clock, _) => fixed("clock", "now"),
776        (EffectKind::Random, _) => fixed("random", "bytes"),
777        (EffectKind::Process, EffectScope::Read | EffectScope::Observe) => {
778            fixed("process", "inspect")
779        }
780        (EffectKind::Process, _) => fixed("process", "run"),
781        (EffectKind::Secret, EffectScope::Read | EffectScope::Observe) => fixed("secrets", "read"),
782        (EffectKind::Secret, _) => fixed("secrets", "write"),
783        (EffectKind::Observability, _) => fixed("observability", "emit"),
784        (EffectKind::Channel, EffectScope::Read | EffectScope::Observe) => fixed("channel", "read"),
785        (EffectKind::Channel, _) => fixed("channel", "write"),
786        (EffectKind::State, EffectScope::Read | EffectScope::Observe) => fixed("state", "read"),
787        (EffectKind::State, _) => fixed("state", "write"),
788        (EffectKind::Host, _) => fixed("connector", "call"),
789        (EffectKind::Authority, scope) => {
790            let access = match scope {
791                EffectScope::Read => harn_builtin_meta::EffectAccess::Read,
792                EffectScope::Write => harn_builtin_meta::EffectAccess::Write,
793                EffectScope::Mutate => harn_builtin_meta::EffectAccess::Mutate,
794                EffectScope::Observe => harn_builtin_meta::EffectAccess::Observe,
795            };
796            let operation = Cow::Owned(harn_ir::authority_effect_policy_operation(
797                access,
798                effect.resource.as_deref(),
799            ));
800            ("authority", operation)
801        }
802        (EffectKind::Llm { .. }, EffectScope::Read) => fixed("llm", "catalog"),
803        (EffectKind::Llm { .. }, _) => fixed("llm", "call"),
804        (EffectKind::Tool { .. }, _) => fixed("host", "tool_call"),
805        (EffectKind::Hostcall { .. }, _) => fixed("connector", "call"),
806        (EffectKind::Persona { .. }, _) => fixed("worker", "dispatch"),
807        (EffectKind::Spawn, _) => fixed("worker", "dispatch"),
808    }
809}
810
811fn side_effect_level_for(effect: &EffectRecord) -> &'static str {
812    match (&effect.kind, effect.scope) {
813        (EffectKind::Stdio, _) => "read_only",
814        (EffectKind::Fs, EffectScope::Read | EffectScope::Observe) => "read_only",
815        (EffectKind::Fs, _) => "workspace_write",
816        (EffectKind::Net, _) => "network",
817        (EffectKind::Env, EffectScope::Read | EffectScope::Observe) => "read_only",
818        (EffectKind::Env, _) => "workspace_write",
819        (EffectKind::Clock, _) => "read_only",
820        (EffectKind::Random, _) => "read_only",
821        (EffectKind::Process, EffectScope::Read | EffectScope::Observe) => "read_only",
822        (EffectKind::Process, _) => "process_exec",
823        (EffectKind::Secret, EffectScope::Read | EffectScope::Observe) => "read_only",
824        (EffectKind::Secret, _) => "workspace_write",
825        (EffectKind::Observability, _) => "read_only",
826        (EffectKind::Channel, EffectScope::Read | EffectScope::Observe) => "read_only",
827        (EffectKind::Channel, _) => "workspace_write",
828        (EffectKind::State, EffectScope::Read | EffectScope::Observe) => "read_only",
829        (EffectKind::State, _) => "workspace_write",
830        (EffectKind::Host, EffectScope::Read | EffectScope::Observe) => "read_only",
831        (EffectKind::Host, _) => "workspace_write",
832        (EffectKind::Authority, EffectScope::Read | EffectScope::Observe) => "read_only",
833        (EffectKind::Authority, _) => "workspace_write",
834        // Model inference consumes an explicitly granted `llm.call`
835        // capability but does not mutate the user's workspace or an external
836        // system. Keep its rich read/write effect scope for lineage and
837        // attenuation while classifying every LLM effect as read-only for the
838        // orthogonal tool side-effect ceiling.
839        (EffectKind::Llm { .. }, _) => "read_only",
840        (EffectKind::Tool { .. }, _) => "workspace_write",
841        (EffectKind::Hostcall { name }, _) if name.starts_with("process.") => "process_exec",
842        (EffectKind::Hostcall { .. }, _) => "read_only",
843        (EffectKind::Persona { .. }, _) => "workspace_write",
844        (EffectKind::Spawn, _) => "workspace_write",
845    }
846}
847
848fn requested_exceeds_ceiling(requested: &str, ceiling: &str) -> bool {
849    fn rank(value: &str) -> usize {
850        crate::tool_annotations::SideEffectLevel::rank_str(value)
851    }
852    rank(requested) > rank(ceiling)
853}
854
855/// Round-trip a typed effect list through the `metadata` map a child
856/// spawn-config carries. Pipelines that pre-compute effects can stash
857/// them under `effects` and the spawn shim lifts them onto the handoff.
858pub fn effects_from_metadata(metadata: &BTreeMap<String, serde_json::Value>) -> Vec<EffectRecord> {
859    metadata
860        .get("effects")
861        .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
862        .unwrap_or_default()
863}
864
865/// Decide whether `child` is covered by `parent`. An effect is covered
866/// when the parent declares another record with the same kind family
867/// and a scope that is at least as permissive. `resource` is treated
868/// best-effort: when the parent carries a non-empty resource it must
869/// match the child's resource exactly (and the child's resource must be
870/// known); when the parent has no resource it covers any resource the
871/// child names. This is the core of E5.4's `HARN-CAP-301` enforcement —
872/// the dispatcher and the static analyzer share one implementation so
873/// preflight and runtime never disagree.
874fn parent_covers_child(parent: &EffectRecord, child: &EffectRecord) -> bool {
875    if !effect_kind_family_matches(&parent.kind, &child.kind) {
876        return false;
877    }
878    if !effect_scope_covers(parent.scope, child.scope) {
879        return false;
880    }
881    match (parent.resource.as_deref(), child.resource.as_deref()) {
882        (Some(""), _) => true,
883        (Some(parent_resource), Some(child_resource)) => parent_resource == child_resource,
884        (Some(_), None) => false,
885        (None, _) => true,
886    }
887}
888
889fn effect_kind_family_matches(parent: &EffectKind, child: &EffectKind) -> bool {
890    match (parent, child) {
891        (EffectKind::Stdio, EffectKind::Stdio)
892        | (EffectKind::Fs, EffectKind::Fs)
893        | (EffectKind::Net, EffectKind::Net)
894        | (EffectKind::Env, EffectKind::Env)
895        | (EffectKind::Clock, EffectKind::Clock)
896        | (EffectKind::Random, EffectKind::Random)
897        | (EffectKind::Process, EffectKind::Process)
898        | (EffectKind::Secret, EffectKind::Secret)
899        | (EffectKind::Observability, EffectKind::Observability)
900        | (EffectKind::Channel, EffectKind::Channel)
901        | (EffectKind::State, EffectKind::State)
902        | (EffectKind::Host, EffectKind::Host)
903        | (EffectKind::Authority, EffectKind::Authority)
904        | (EffectKind::Spawn, EffectKind::Spawn) => true,
905        (EffectKind::Llm { .. }, EffectKind::Llm { .. }) => true,
906        (
907            EffectKind::Tool {
908                name: parent_name, ..
909            },
910            EffectKind::Tool {
911                name: child_name, ..
912            },
913        ) => parent_name.is_empty() || parent_name == child_name,
914        (
915            EffectKind::Hostcall {
916                name: parent_name, ..
917            },
918            EffectKind::Hostcall {
919                name: child_name, ..
920            },
921        ) => parent_name.is_empty() || parent_name == child_name,
922        (EffectKind::Persona { id: parent_id }, EffectKind::Persona { id: child_id }) => {
923            parent_id.is_empty() || parent_id == child_id
924        }
925        _ => false,
926    }
927}
928
929fn effect_scope_covers(parent: EffectScope, child: EffectScope) -> bool {
930    fn rank(scope: EffectScope) -> u8 {
931        match scope {
932            EffectScope::Read => 1,
933            EffectScope::Observe => 1,
934            EffectScope::Write => 2,
935            EffectScope::Mutate => 3,
936        }
937    }
938    rank(parent) >= rank(child)
939}
940
941/// Compute the subset of `child` effects that are not covered by any
942/// record in `parent`. An empty parent set is treated as "no declared
943/// effects" — under E5.4 the dispatcher takes that to mean every child
944/// effect is a violation, because a child can never out-grant an
945/// undeclared parent. When `parent` is `None` enforcement is skipped
946/// entirely (the caller has decided no static ceiling applies).
947pub fn effect_subset_violations(
948    parent: Option<&[EffectRecord]>,
949    child: &[EffectRecord],
950) -> Vec<EffectRecord> {
951    let Some(parent) = parent else {
952        return Vec::new();
953    };
954    child
955        .iter()
956        .filter(|effect| {
957            !parent
958                .iter()
959                .any(|allowed| parent_covers_child(allowed, effect))
960        })
961        .cloned()
962        .collect()
963}
964
965/// Short human-readable label for `effect.kind` used in
966/// `EffectInheritanceViolation` messages and `HARN-CAP-301` diagnostics.
967pub fn effect_kind_label(kind: &EffectKind) -> String {
968    match kind {
969        EffectKind::Stdio => "stdio".to_string(),
970        EffectKind::Fs => "fs".to_string(),
971        EffectKind::Net => "net".to_string(),
972        EffectKind::Env => "env".to_string(),
973        EffectKind::Clock => "clock".to_string(),
974        EffectKind::Random => "random".to_string(),
975        EffectKind::Process => "process".to_string(),
976        EffectKind::Secret => "secret".to_string(),
977        EffectKind::Observability => "observability".to_string(),
978        EffectKind::Channel => "channel".to_string(),
979        EffectKind::State => "state".to_string(),
980        EffectKind::Host => "host".to_string(),
981        EffectKind::Authority => "authority".to_string(),
982        EffectKind::Llm { provider, model } => match (provider.as_deref(), model.as_deref()) {
983            (Some(provider), Some(model)) => format!("llm:{provider}/{model}"),
984            (Some(provider), None) => format!("llm:{provider}"),
985            (None, Some(model)) => format!("llm:{model}"),
986            (None, None) => "llm".to_string(),
987        },
988        EffectKind::Tool { name } if !name.is_empty() => format!("tool:{name}"),
989        EffectKind::Tool { .. } => "tool".to_string(),
990        EffectKind::Hostcall { name } if !name.is_empty() => format!("hostcall:{name}"),
991        EffectKind::Hostcall { .. } => "hostcall".to_string(),
992        EffectKind::Persona { id } if !id.is_empty() => format!("persona:{id}"),
993        EffectKind::Persona { .. } => "persona".to_string(),
994        EffectKind::Spawn => "spawn".to_string(),
995    }
996}
997
998/// One-line summary suitable for diagnostic messages and deny events.
999pub fn effect_record_summary(effect: &EffectRecord) -> String {
1000    let scope = match effect.scope {
1001        EffectScope::Read => "read",
1002        EffectScope::Write => "write",
1003        EffectScope::Mutate => "mutate",
1004        EffectScope::Observe => "observe",
1005    };
1006    match effect.resource.as_deref() {
1007        Some(resource) if !resource.is_empty() => {
1008            format!(
1009                "{}:{} ({})",
1010                effect_kind_label(&effect.kind),
1011                scope,
1012                resource
1013            )
1014        }
1015        _ => format!("{}:{}", effect_kind_label(&effect.kind), scope),
1016    }
1017}
1018
1019#[cfg(test)]
1020#[path = "effects_authority_tests.rs"]
1021mod authority_tests;
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026
1027    #[test]
1028    fn harness_net_call_yields_net_effect() {
1029        let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test") }"#;
1030        let effects = compute_handoff_effects(source, None);
1031        assert!(
1032            effects
1033                .iter()
1034                .any(|effect| matches!(effect.kind, EffectKind::Net)
1035                    && effect.scope == EffectScope::Read
1036                    && effect.resource.as_deref() == Some("https://example.test")),
1037            "expected Net read effect, got {effects:?}"
1038        );
1039    }
1040
1041    #[test]
1042    fn harness_process_run_yields_process_hostcall_effect() {
1043        let source = r#"fn main(harness: Harness) {
1044            harness.process.run({program: "printf", args: ["hello"]})
1045        }"#;
1046        let effects = compute_handoff_effects(source, None);
1047        assert!(
1048            effects.iter().any(|effect| {
1049                matches!(&effect.kind, EffectKind::Process)
1050                    && effect.scope == EffectScope::Write
1051                    && effect.resource.as_deref() == Some("printf")
1052            }),
1053            "expected process hostcall write effect, got {effects:?}"
1054        );
1055    }
1056
1057    #[test]
1058    fn http_get_builtin_yields_net_effect_with_resource() {
1059        let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test/api") }"#;
1060        let effects = compute_handoff_effects(source, None);
1061        let net = effects
1062            .iter()
1063            .find(|effect| matches!(effect.kind, EffectKind::Net))
1064            .expect("net effect");
1065        assert_eq!(net.scope, EffectScope::Read);
1066        assert_eq!(net.resource.as_deref(), Some("https://example.test/api"));
1067    }
1068
1069    #[test]
1070    fn unix_socket_json_request_yields_net_effect_with_resource() {
1071        let source = r#"fn main(harness: Harness) {
1072            harness.net.unix_socket_json_request("/tmp/harn.sock", {})
1073        }"#;
1074        let effects = compute_handoff_effects(source, None);
1075        let net = effects
1076            .iter()
1077            .find(|effect| matches!(effect.kind, EffectKind::Net))
1078            .expect("net effect");
1079        assert_eq!(net.scope, EffectScope::Mutate);
1080        assert_eq!(net.resource.as_deref(), Some("/tmp/harn.sock"));
1081    }
1082
1083    #[test]
1084    fn files_upload_yields_fs_read_and_net_write_effects() {
1085        let source = r#"fn main(harness: Harness) {
1086            harness.llm.upload_file("/tmp/input.pdf", "gemini")
1087        }"#;
1088        let effects = compute_handoff_effects(source, None);
1089        assert!(
1090            effects.iter().any(|effect| {
1091                matches!(effect.kind, EffectKind::Fs)
1092                    && effect.scope == EffectScope::Read
1093                    && effect.resource.as_deref() == Some("/tmp/input.pdf")
1094            }),
1095            "expected Fs read effect, got {effects:?}"
1096        );
1097        assert!(
1098            effects.iter().any(|effect| {
1099                matches!(effect.kind, EffectKind::Net)
1100                    && effect.scope == EffectScope::Write
1101                    && effect.resource.as_deref() == Some("gemini")
1102            }),
1103            "expected Net write effect, got {effects:?}"
1104        );
1105    }
1106
1107    #[test]
1108    fn harness_fs_write_yields_fs_write_effect() {
1109        let source = r#"fn main(harness: Harness) { harness.fs.write_text("/tmp/out", "hi") }"#;
1110        let effects = compute_handoff_effects(source, None);
1111        assert!(
1112            effects
1113                .iter()
1114                .any(|effect| matches!(effect.kind, EffectKind::Fs)
1115                    && effect.scope == EffectScope::Write
1116                    && effect.resource.as_deref() == Some("/tmp/out")),
1117            "expected Fs write effect, got {effects:?}"
1118        );
1119    }
1120
1121    #[test]
1122    fn granular_capability_parameter_preserves_effect_contract() {
1123        let source = r#"
1124fn write_output(fs: HarnessFs) {
1125    fs.write_text("/tmp/out", "hi")
1126}
1127
1128fn main(harness: Harness) {
1129    write_output(harness.fs)
1130}
1131"#;
1132        let effects = compute_handoff_effects(source, None);
1133        assert!(
1134            effects.iter().any(|effect| {
1135                matches!(effect.kind, EffectKind::Fs)
1136                    && effect.scope == EffectScope::Write
1137                    && effect.resource.as_deref() == Some("/tmp/out")
1138            }),
1139            "expected granular HarnessFs effect, got {effects:?}"
1140        );
1141    }
1142
1143    #[test]
1144    fn capability_alias_preserves_effect_contract() {
1145        let source = r#"
1146fn main(harness: Harness) {
1147    const fs = harness.fs
1148    fs.write_text("/tmp/out", "hi")
1149}
1150"#;
1151        let effects = compute_handoff_effects(source, None);
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/out")
1157            }),
1158            "expected aliased HarnessFs effect, got {effects:?}"
1159        );
1160    }
1161
1162    #[test]
1163    fn capability_method_can_declare_multiple_effects() {
1164        let source = r#"fn main(harness: Harness) {
1165            harness.net.download("https://example.test/data", "/tmp/data")
1166        }"#;
1167        let effects = compute_handoff_effects(source, None);
1168        assert!(
1169            effects.iter().any(|effect| {
1170                matches!(effect.kind, EffectKind::Net)
1171                    && effect.scope == EffectScope::Read
1172                    && effect.resource.as_deref() == Some("https://example.test/data")
1173            }),
1174            "expected download network effect, got {effects:?}"
1175        );
1176        assert!(
1177            effects.iter().any(|effect| {
1178                matches!(effect.kind, EffectKind::Fs)
1179                    && effect.scope == EffectScope::Write
1180                    && effect.resource.as_deref() == Some("/tmp/data")
1181            }),
1182            "expected download filesystem effect, got {effects:?}"
1183        );
1184    }
1185
1186    #[test]
1187    fn harness_term_read_password_yields_stdio_read_effect() {
1188        let source = r#"fn main(harness: Harness) { harness.term.read_password("password: ") }"#;
1189        let effects = compute_handoff_effects(source, None);
1190        assert!(
1191            effects
1192                .iter()
1193                .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1194                    && effect.scope == EffectScope::Read),
1195            "expected Stdio read effect, got {effects:?}"
1196        );
1197    }
1198
1199    #[test]
1200    fn harness_fs_mkdtemp_yields_fs_write_effect() {
1201        let source = r#"fn main(harness: Harness) { harness.fs.mkdtemp("harn-") }"#;
1202        let effects = compute_handoff_effects(source, None);
1203        assert!(
1204            effects
1205                .iter()
1206                .any(|effect| matches!(effect.kind, EffectKind::Fs)
1207                    && effect.scope == EffectScope::Write),
1208            "expected Fs write effect, got {effects:?}"
1209        );
1210    }
1211
1212    #[test]
1213    fn harness_crypto_sha256_is_pure_for_handoff_effects() {
1214        let source = r#"fn main(harness: Harness) { sha256_hex("hello") }"#;
1215        let effects = compute_handoff_effects(source, None);
1216        assert!(effects.is_empty(), "expected no effects, got {effects:?}");
1217    }
1218
1219    #[test]
1220    fn harness_stdio_read_line_yields_stdio_read_effect() {
1221        let source = r"fn main(harness: Harness) { harness.stdio.read_line() }";
1222        let effects = compute_handoff_effects(source, None);
1223        assert!(
1224            effects
1225                .iter()
1226                .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1227                    && effect.scope == EffectScope::Read),
1228            "expected Stdio read effect, got {effects:?}"
1229        );
1230    }
1231
1232    #[test]
1233    fn llm_call_emits_llm_effect_with_provider_and_model() {
1234        let source = r#"fn main(harness: Harness) {
1235            harness.llm.call(
1236                "summarize",
1237                nil,
1238                { provider: "anthropic", model: "claude-3-5-sonnet" },
1239            )
1240        }"#;
1241        let effects = compute_handoff_effects(source, None);
1242        let llm = effects
1243            .iter()
1244            .find(|effect| matches!(effect.kind, EffectKind::Llm { .. }))
1245            .expect("llm effect");
1246        let EffectKind::Llm { provider, model } = &llm.kind else {
1247            panic!("expected llm kind, got {:?}", llm.kind);
1248        };
1249        assert_eq!(provider.as_deref(), Some("anthropic"));
1250        assert_eq!(model.as_deref(), Some("claude-3-5-sonnet"));
1251    }
1252
1253    #[test]
1254    fn runtime_llm_contract_combines_provider_and_model_resources() {
1255        let entry = crate::stdlib::builtin_manifest_entry("__cap_llm_call")
1256            .expect("LLM capability manifest entry");
1257        let options = VmValue::dict(crate::value::DictMap::from_iter([
1258            ("provider", VmValue::String("anthropic".into())),
1259            ("model", VmValue::String("claude-sonnet-4".into())),
1260        ]));
1261        let effects = runtime_effects_from_contract(
1262            entry.contract.effects,
1263            &[VmValue::Nil, VmValue::Nil, options],
1264        );
1265        assert_eq!(effects.len(), 1);
1266        assert!(matches!(
1267            &effects[0].kind,
1268            EffectKind::Llm { provider: Some(provider), model: Some(model) }
1269                if provider == "anthropic" && model == "claude-sonnet-4"
1270        ));
1271    }
1272
1273    #[test]
1274    fn harness_llm_catalog_yields_read_effect() {
1275        let source = r"fn main(harness: Harness) {
1276            harness.llm.catalog()
1277            harness.llm.providers()
1278        }";
1279        let effects = compute_handoff_effects(source, None);
1280        assert!(
1281            effects
1282                .iter()
1283                .any(|effect| matches!(effect.kind, EffectKind::Llm { .. })
1284                    && effect.scope == EffectScope::Read),
1285            "expected LLM read effect, got {effects:?}"
1286        );
1287    }
1288
1289    #[test]
1290    fn ceiling_drops_disallowed_capabilities() {
1291        let source = r#"fn main(harness: Harness) {
1292            harness.net.get("https://example.test")
1293            harness.fs.read_text("/tmp/in")
1294        }"#;
1295        let mut ceiling = CapabilityPolicy::default();
1296        ceiling
1297            .capabilities
1298            .insert("workspace".to_string(), vec!["read_text".to_string()]);
1299        let effects = compute_handoff_effects(source, Some(&ceiling));
1300        assert!(
1301            effects
1302                .iter()
1303                .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1304            "ceiling without `network` should drop Net effect, got {effects:?}"
1305        );
1306        assert!(
1307            effects
1308                .iter()
1309                .any(|effect| matches!(effect.kind, EffectKind::Fs)),
1310            "ceiling with workspace.read_text should keep Fs read, got {effects:?}"
1311        );
1312    }
1313
1314    #[test]
1315    fn ceiling_side_effect_level_clamps_writes() {
1316        let source = r#"fn main(harness: Harness) {
1317            harness.net.get("https://example.test")
1318            harness.stdio.println("hi")
1319        }"#;
1320        let ceiling = CapabilityPolicy {
1321            side_effect_level: Some("read_only".to_string()),
1322            ..Default::default()
1323        };
1324        let effects = compute_handoff_effects(source, Some(&ceiling));
1325        assert!(
1326            effects
1327                .iter()
1328                .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1329            "read_only ceiling must drop Net write, got {effects:?}"
1330        );
1331        assert!(
1332            effects
1333                .iter()
1334                .any(|effect| matches!(effect.kind, EffectKind::Stdio)),
1335            "stdio observe should pass read_only ceiling, got {effects:?}"
1336        );
1337    }
1338
1339    #[test]
1340    fn effect_record_round_trips_through_serde() {
1341        let effects = vec![
1342            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1343                .with_resource("https://api.example/v1"),
1344            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1345            EffectRecord::new(
1346                EffectKind::Llm {
1347                    provider: Some("anthropic".to_string()),
1348                    model: Some("claude-3-7-sonnet".to_string()),
1349                },
1350                EffectScope::Write,
1351            ),
1352            EffectRecord::new(
1353                EffectKind::Tool {
1354                    name: "search".to_string(),
1355                },
1356                EffectScope::Read,
1357            ),
1358        ];
1359        let encoded = serde_json::to_string(&effects).expect("encode");
1360        let decoded: Vec<EffectRecord> = serde_json::from_str(&encoded).expect("decode");
1361        assert_eq!(decoded, effects);
1362    }
1363
1364    #[test]
1365    fn empty_source_returns_no_effects() {
1366        let effects = compute_handoff_effects("fn main() {}", None);
1367        assert!(effects.is_empty(), "got {effects:?}");
1368    }
1369
1370    #[test]
1371    fn effects_from_metadata_round_trips_typed_payload() {
1372        let effects = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1373            .with_resource("https://api.example")];
1374        let mut metadata: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1375        metadata.insert(
1376            "effects".to_string(),
1377            serde_json::to_value(&effects).expect("encode"),
1378        );
1379        assert_eq!(effects_from_metadata(&metadata), effects);
1380    }
1381
1382    #[test]
1383    fn subset_violations_returns_empty_when_child_covered() {
1384        let parent = vec![
1385            EffectRecord::new(EffectKind::Net, EffectScope::Write),
1386            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1387        ];
1388        let child = vec![
1389            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1390                .with_resource("https://example.test"),
1391            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1392        ];
1393        assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1394    }
1395
1396    #[test]
1397    fn subset_violations_flags_unmatched_kinds() {
1398        let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1399        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1400            .with_resource("https://example.test")];
1401        let violations = effect_subset_violations(Some(&parent), &child);
1402        assert_eq!(violations.len(), 1);
1403        assert!(matches!(violations[0].kind, EffectKind::Net));
1404    }
1405
1406    #[test]
1407    fn subset_violations_flags_scope_escalations() {
1408        let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1409        let child = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)];
1410        let violations = effect_subset_violations(Some(&parent), &child);
1411        assert_eq!(violations.len(), 1);
1412        assert_eq!(violations[0].scope, EffectScope::Mutate);
1413    }
1414
1415    #[test]
1416    fn subset_violations_treats_missing_parent_resource_as_wildcard() {
1417        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1418        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1419            .with_resource("https://api.example/v1")];
1420        assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1421    }
1422
1423    #[test]
1424    fn subset_violations_requires_resource_match_when_parent_declares_one() {
1425        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1426            .with_resource("https://allowed.test")];
1427        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1428            .with_resource("https://disallowed.test")];
1429        let violations = effect_subset_violations(Some(&parent), &child);
1430        assert_eq!(violations.len(), 1);
1431    }
1432
1433    #[test]
1434    fn subset_violations_skip_when_parent_is_none() {
1435        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1436        assert!(effect_subset_violations(None, &child).is_empty());
1437    }
1438
1439    #[test]
1440    fn subset_violations_empty_parent_flags_every_child_effect() {
1441        let parent: Vec<EffectRecord> = Vec::new();
1442        let child = vec![
1443            EffectRecord::new(EffectKind::Net, EffectScope::Write),
1444            EffectRecord::new(EffectKind::Fs, EffectScope::Read),
1445        ];
1446        let violations = effect_subset_violations(Some(&parent), &child);
1447        assert_eq!(violations.len(), 2);
1448    }
1449
1450    #[test]
1451    fn subset_violations_empty_child_is_always_allowed() {
1452        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1453        assert!(effect_subset_violations(Some(&parent), &[]).is_empty());
1454    }
1455
1456    #[test]
1457    fn effect_kind_label_shape() {
1458        assert_eq!(effect_kind_label(&EffectKind::Net), "net");
1459        assert_eq!(
1460            effect_kind_label(&EffectKind::Llm {
1461                provider: Some("anthropic".to_string()),
1462                model: Some("claude-3-7-sonnet".to_string()),
1463            }),
1464            "llm:anthropic/claude-3-7-sonnet"
1465        );
1466        assert_eq!(
1467            effect_kind_label(&EffectKind::Tool {
1468                name: "search".to_string()
1469            }),
1470            "tool:search"
1471        );
1472    }
1473
1474    #[test]
1475    fn effect_record_summary_includes_resource() {
1476        let effect = EffectRecord::new(EffectKind::Net, EffectScope::Write)
1477            .with_resource("https://example.test/api");
1478        assert_eq!(
1479            effect_record_summary(&effect),
1480            "net:write (https://example.test/api)"
1481        );
1482    }
1483
1484    #[test]
1485    fn deduplicates_repeated_effects() {
1486        let source = r#"fn main(harness: Harness) {
1487            harness.net.get("https://example.test")
1488            harness.net.get("https://example.test")
1489            harness.net.get("https://example.test")
1490        }"#;
1491        let effects = compute_handoff_effects(source, None);
1492        let net_count = effects
1493            .iter()
1494            .filter(|effect| matches!(effect.kind, EffectKind::Net))
1495            .count();
1496        assert_eq!(net_count, 1, "expected dedup, got {effects:?}");
1497    }
1498}