Skip to main content

harn_vm/orchestration/policy/
effects.rs

1//! Typed effect records carried on `HandoffArtifact` envelopes.
2//!
3//! `EffectRecord` is the leaf payload that names a single side-effect a
4//! spawned child agent may exercise (e.g. `Net write to https://api.example`,
5//! `Fs read of /workspace/src`). The set sits on each handoff so the
6//! dispatcher (E5.4) and the OpenTrustGraph receipt chain (E5.5) can prove
7//! the child never escaped its parent's effect grant.
8//!
9//! Computation at spawn time walks the child's entrypoint module via the
10//! same capability analysis `harn graph --json` uses (issue HARN-#1758),
11//! plus a conservative AST walker for harness calls embedded in inline spawn
12//! configs. The two extraction paths feed one canonicalization step so
13//! downstream consumers see a single deduped, deterministically ordered list.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use serde::{Deserialize, Serialize};
18
19use harn_ir::{CallClassification, Capability, LiteralValue, NodeSemantics};
20use harn_parser::{Node, SNode};
21
22use super::CapabilityPolicy;
23
24/// Discriminator for the kind of effect captured. Matches the
25/// classification used by the OpenTrustGraph receipt format (E5.5).
26#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
27#[serde(tag = "kind", rename_all = "snake_case")]
28pub enum EffectKind {
29    /// Reads or writes against the host's stdio streams.
30    Stdio,
31    /// Filesystem access (read, write, list, delete, ...).
32    Fs,
33    /// Network access (HTTP, SSE, WebSocket).
34    Net,
35    /// LLM model calls — captures the provider and model when statically
36    /// known so the receipt chain can name the inference dependency.
37    Llm {
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        provider: Option<String>,
40        #[serde(default, skip_serializing_if = "Option::is_none")]
41        model: Option<String>,
42    },
43    /// Pipeline-declared tool dispatched through the agent loop.
44    Tool { name: String },
45    /// Bridged host capability call (`host_call(capability.operation, ...)`).
46    Hostcall { name: String },
47    /// Targeted delegation to a named persona / sub-agent identity.
48    Persona { id: String },
49    /// Spawn / sub-agent / worker dispatch primitives.
50    Spawn,
51}
52
53/// What kind of interaction the effect represents. Mirrors the
54/// `read | write | mutate | observe` taxonomy the receipt schema uses.
55#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
56#[serde(rename_all = "snake_case")]
57pub enum EffectScope {
58    /// Pure read: no observable state change for other actors.
59    Read,
60    /// Write that creates or replaces state owned by this effect.
61    Write,
62    /// Mutation of state that may already be observed by other actors.
63    Mutate,
64    /// Side-channel observation (stdio sink, telemetry emission, ...).
65    Observe,
66}
67
68/// Single typed effect carried on a `HandoffArtifact.effects` entry.
69///
70/// `resource` is an opaque, statically-known target identifier (path,
71/// URL, tool id, persona id). The dispatcher (E5.4) is free to enforce
72/// ⊆ against the resource string; when no resource can be derived the
73/// field stays `None`.
74#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
75pub struct EffectRecord {
76    pub kind: EffectKind,
77    pub scope: EffectScope,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub resource: Option<String>,
80}
81
82impl EffectRecord {
83    pub fn new(kind: EffectKind, scope: EffectScope) -> Self {
84        Self {
85            kind,
86            scope,
87            resource: None,
88        }
89    }
90
91    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
92        let resource = resource.into();
93        self.resource = if resource.is_empty() {
94            None
95        } else {
96            Some(resource)
97        };
98        self
99    }
100}
101
102/// Compute the effect set for a child agent's entrypoint module.
103///
104/// Parses `source`, walks the resulting AST via the same `harn_ir`
105/// capability analyzer that backs `harn graph --json`, and supplements it with
106/// a direct walk for harness calls in inline spawn configs. The result is
107/// deterministically ordered and deduplicated.
108///
109/// When `ceiling` is provided, the result is clamped to it: an effect
110/// is dropped if the ceiling's `capabilities` map is non-empty and does
111/// not allow the matching capability/op, or if the effect's
112/// `side_effect_level` exceeds the ceiling's `side_effect_level`. Empty
113/// ceilings are treated as "no constraint" — the same convention the
114/// rest of the policy machinery uses.
115pub fn compute_handoff_effects(
116    source: &str,
117    ceiling: Option<&CapabilityPolicy>,
118) -> Vec<EffectRecord> {
119    let Ok(program) = harn_parser::parse_source(source) else {
120        return Vec::new();
121    };
122    let mut collected: BTreeSet<EffectRecord> = BTreeSet::new();
123
124    // Builtin / host-call effects via the existing IR analyzer — same
125    // surface `harn graph --json` reads.
126    let report = harn_ir::analyze_program(&program);
127    for handler in &report.handlers {
128        for node in &handler.nodes {
129            let NodeSemantics::Call(call) = &node.semantics else {
130                continue;
131            };
132            for effect in effects_from_call(call) {
133                collected.insert(effect);
134            }
135        }
136    }
137
138    // Spawn preflight also wraps object-literal configs and inline closures
139    // where the IR handler pass cannot always attribute harness calls. Keep
140    // this broad direct pass so parent/child effect checks stay conservative.
141    for node in &program {
142        walk_for_harness_effects(node, &mut collected);
143    }
144
145    let mut effects: Vec<EffectRecord> = collected.into_iter().collect();
146    if let Some(ceiling) = ceiling {
147        effects.retain(|effect| effect_allowed_by_ceiling(effect, ceiling));
148    }
149    effects
150}
151
152fn effects_from_call(call: &harn_ir::CallSemantics) -> Vec<EffectRecord> {
153    // Primary extraction: name-based builtin recognition. This path
154    // carries the richest information (resource from literal args,
155    // provider/model on LLM calls) and is the authoritative shape.
156    if let Some(effect) = builtin_effect(&call.name) {
157        return vec![annotate_with_resource(effect, call)];
158    }
159    if call.name == "host_call" {
160        if let Some(operation) = call.literal_args.first().and_then(literal_as_str) {
161            return vec![EffectRecord::new(
162                EffectKind::Hostcall {
163                    name: operation.to_string(),
164                },
165                hostcall_scope(operation),
166            )];
167        }
168    }
169    // Fallback: pipeline-declared tools and other capabilities the
170    // builtin recognizer doesn't know about (custom tool_call dispatch,
171    // user-defined capability classifications). Only consulted when the
172    // primary extraction returns nothing — same call shouldn't produce
173    // two records with different `resource` fields just because two
174    // extraction paths happen to match.
175    if let CallClassification::Capabilities(capability_effects) = &call.classification {
176        return capability_effects
177            .iter()
178            .filter_map(capability_effect_to_record)
179            .collect();
180    }
181    Vec::new()
182}
183
184fn builtin_effect(name: &str) -> Option<EffectRecord> {
185    match name {
186        // stdio
187        "print" | "println" | "eprint" | "eprintln" | "write_stdout" | "write_stderr"
188        | "__io_print" | "__io_println" | "__io_eprint" | "__io_eprintln" | "__io_write_stdout"
189        | "__io_write_stderr" => Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Observe)),
190        "read_line" | "read_stdin" | "prompt_user" | "__io_read_line" => {
191            Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Read))
192        }
193
194        // fs reads
195        "read_file"
196        | "read_file_bytes"
197        | "read_file_result"
198        | "render"
199        | "render_prompt"
200        | "render_with_provenance"
201        | "read_lines"
202        | "list_dir"
203        | "walk_dir"
204        | "glob"
205        | "file_exists"
206        | "stat" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Read)),
207
208        // fs writes
209        "write_file" | "write_file_bytes" | "append_file" | "mkdir" | "mkdtemp" | "copy_file"
210        | "move_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Write)),
211        "delete_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
212        "apply_edit" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
213
214        // network — mirrors `is_network_call` in harn-ir; the EffectKind
215        // is identical for every transport because the dispatcher (E5.4)
216        // enforces the ⊆ relation at the `Net` granularity, not per-verb.
217        "http_get"
218        | "http_post"
219        | "http_put"
220        | "http_patch"
221        | "http_delete"
222        | "http_request"
223        | "http_download"
224        | "http_session"
225        | "http_session_request"
226        | "http_session_close"
227        | "http_stream_open"
228        | "http_stream_read"
229        | "http_stream_close"
230        | "sse_connect"
231        | "sse_receive"
232        | "sse_close"
233        | "sse_server_response"
234        | "sse_server_send"
235        | "sse_server_heartbeat"
236        | "sse_server_flush"
237        | "sse_server_close"
238        | "sse_server_cancel"
239        | "websocket_connect"
240        | "websocket_accept"
241        | "websocket_send"
242        | "websocket_receive"
243        | "websocket_close"
244        | "websocket_route"
245        | "websocket_server"
246        | "websocket_server_close" => Some(EffectRecord::new(EffectKind::Net, EffectScope::Write)),
247
248        // llm
249        "llm_call"
250        | "llm_call_safe"
251        | "llm_stream_call"
252        | "llm_call_structured"
253        | "llm_call_structured_safe"
254        | "llm_call_structured_result"
255        | "llm_completion"
256        | "agent_llm_turn"
257        | "agent_turn"
258        | "agent_loop" => Some(EffectRecord::new(
259            EffectKind::Llm {
260                provider: None,
261                model: None,
262            },
263            EffectScope::Write,
264        )),
265        "llm_catalog" | "llm_provider_status" => Some(EffectRecord::new(
266            EffectKind::Llm {
267                provider: None,
268                model: None,
269            },
270            EffectScope::Read,
271        )),
272
273        // spawn / worker dispatch
274        "spawn_agent"
275        | "send_input"
276        | "resume_agent"
277        | "wait_agent"
278        | "close_agent"
279        | "worker_trigger"
280        | "__host_sub_agent_run"
281        | "__host_worker_spawn"
282        | "__host_worker_send_input"
283        | "__host_worker_resume"
284        | "__host_worker_trigger"
285        | "__host_worker_wait"
286        | "__host_worker_close" => Some(EffectRecord::new(EffectKind::Spawn, EffectScope::Write)),
287
288        // pipeline-declared tools dispatched through tool_call
289        "tool_call" | "host_tool_call" => Some(EffectRecord::new(
290            EffectKind::Tool {
291                name: String::new(),
292            },
293            EffectScope::Write,
294        )),
295
296        _ => None,
297    }
298}
299
300fn annotate_with_resource(mut effect: EffectRecord, call: &harn_ir::CallSemantics) -> EffectRecord {
301    // Effect resources are best-effort: first literal arg (path / url /
302    // tool name) when statically derivable. Unknown literals stay
303    // unannotated rather than guessed.
304    match &mut effect.kind {
305        EffectKind::Llm { provider, model } => {
306            for arg in &call.literal_args {
307                if let LiteralValue::Dict(entries) = arg {
308                    if let Some(value) = entries.get("provider").and_then(literal_as_str) {
309                        *provider = Some(value.to_string());
310                    }
311                    if let Some(value) = entries.get("model").and_then(literal_as_str) {
312                        *model = Some(value.to_string());
313                    }
314                }
315            }
316        }
317        EffectKind::Tool { name } => {
318            if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
319                *name = value.to_string();
320            }
321        }
322        _ => {
323            if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
324                effect.resource = Some(value.to_string());
325            }
326        }
327    }
328    effect
329}
330
331fn capability_effect_to_record(effect: &harn_ir::CapabilityEffect) -> Option<EffectRecord> {
332    let (kind, scope) = match effect.capability {
333        Capability::WorkspaceMutation => (EffectKind::Fs, EffectScope::Mutate),
334        Capability::CommandExecution => (
335            EffectKind::Hostcall {
336                name: format!("process.{}", effect.operation),
337            },
338            EffectScope::Write,
339        ),
340        Capability::NetworkAccess => (EffectKind::Net, EffectScope::Write),
341        Capability::ConnectorAccess => (
342            EffectKind::Hostcall {
343                name: if effect.operation.is_empty() {
344                    "connector.call".to_string()
345                } else {
346                    format!("connector.{}", effect.operation)
347                },
348            },
349            EffectScope::Write,
350        ),
351        Capability::ModelCall => (
352            EffectKind::Llm {
353                provider: None,
354                model: None,
355            },
356            EffectScope::Write,
357        ),
358        Capability::WorkerDispatch => (EffectKind::Spawn, EffectScope::Write),
359        Capability::HumanApproval => return None,
360        Capability::AutonomyPolicy => return None,
361    };
362    let resource = effect.path.clone();
363    Some(EffectRecord {
364        kind,
365        scope,
366        resource,
367    })
368}
369
370fn hostcall_scope(operation: &str) -> EffectScope {
371    match operation {
372        op if op.starts_with("workspace.read") || op.starts_with("workspace.list") => {
373            EffectScope::Read
374        }
375        op if op.starts_with("workspace.write") || op == "workspace.apply_edit" => {
376            EffectScope::Mutate
377        }
378        op if op.starts_with("process.") => EffectScope::Write,
379        _ => EffectScope::Write,
380    }
381}
382
383fn literal_as_str(value: &LiteralValue) -> Option<&str> {
384    match value {
385        LiteralValue::String(value) | LiteralValue::Identifier(value) => Some(value.as_str()),
386        _ => None,
387    }
388}
389
390fn walk_for_harness_effects(node: &SNode, out: &mut BTreeSet<EffectRecord>) {
391    if let Some(effect) = harness_method_effect(node) {
392        out.insert(effect);
393    }
394    for child in child_nodes(node) {
395        walk_for_harness_effects(child, out);
396    }
397}
398
399fn harness_method_effect(node: &SNode) -> Option<EffectRecord> {
400    let (object, method) = match &node.node {
401        Node::MethodCall { object, method, .. }
402        | Node::OptionalMethodCall { object, method, .. } => (object, method),
403        _ => return None,
404    };
405    let (sub_handle, root) = harness_sub_handle(object)?;
406    if !is_harness_root(root) {
407        return None;
408    }
409    let (kind, scope) = match (sub_handle.as_str(), method.as_str()) {
410        ("stdio", "print" | "println" | "eprint" | "eprintln") => {
411            (EffectKind::Stdio, EffectScope::Observe)
412        }
413        ("stdio", "read_line" | "prompt") => (EffectKind::Stdio, EffectScope::Read),
414        ("term", "width" | "height" | "read_password") => (EffectKind::Stdio, EffectScope::Read),
415        ("clock", _) => return None,
416        ("env", "set" | "unset") => (
417            EffectKind::Hostcall {
418                name: "env.set".to_string(),
419            },
420            EffectScope::Mutate,
421        ),
422        ("env", _) => (
423            EffectKind::Hostcall {
424                name: "env.get".to_string(),
425            },
426            EffectScope::Read,
427        ),
428        ("random", _) => return None,
429        ("fs", "read_file" | "read_text" | "read" | "exists" | "list_dir" | "stat") => {
430            (EffectKind::Fs, EffectScope::Read)
431        }
432        ("fs", "write_file" | "write_text" | "append_file" | "mkdir" | "mkdtemp" | "copy_file") => {
433            (EffectKind::Fs, EffectScope::Write)
434        }
435        ("fs", "delete_file" | "delete" | "remove") => (EffectKind::Fs, EffectScope::Mutate),
436        ("fs", _) => (EffectKind::Fs, EffectScope::Read),
437        ("net", _) => (EffectKind::Net, EffectScope::Write),
438        ("process", "spawn_captured") => (
439            EffectKind::Hostcall {
440                name: "process.spawn_captured".to_string(),
441            },
442            EffectScope::Write,
443        ),
444        ("crypto", "sha256") => return None,
445        // System-introspection methods (`cpu`, `memory`, `gpus`,
446        // `temperature`, `platform`, `processes`) are pure host reads
447        // — no state mutation, no resource consumed. They're gated by
448        // the harness capability handle itself, so deny-by-default
449        // policies still block them, but they don't produce a typed
450        // effect record for child grant enforcement.
451        ("system", _) => return None,
452        ("llm", "catalog" | "providers") => (
453            EffectKind::Llm {
454                provider: None,
455                model: None,
456            },
457            EffectScope::Read,
458        ),
459        ("llm", _) => return None,
460        _ => return None,
461    };
462    Some(EffectRecord::new(kind, scope))
463}
464
465fn harness_sub_handle(node: &SNode) -> Option<(String, &SNode)> {
466    match &node.node {
467        Node::PropertyAccess { object, property }
468        | Node::OptionalPropertyAccess { object, property } => {
469            Some((property.clone(), object.as_ref()))
470        }
471        _ => None,
472    }
473}
474
475fn is_harness_root(node: &SNode) -> bool {
476    matches!(&node.node, Node::Identifier(name) if name == "harness")
477}
478
479fn child_nodes(node: &SNode) -> Vec<&SNode> {
480    let mut children: Vec<&SNode> = Vec::new();
481    match &node.node {
482        Node::AttributedDecl { inner, .. } => children.push(inner.as_ref()),
483        Node::Pipeline { body, .. }
484        | Node::FnDecl { body, .. }
485        | Node::ToolDecl { body, .. }
486        | Node::SpawnExpr { body }
487        | Node::Retry { body, .. }
488        | Node::TryExpr { body }
489        | Node::DeferStmt { body }
490        | Node::MutexBlock { body }
491        | Node::Block(body)
492        | Node::OverrideDecl { body, .. } => children.extend(body.iter()),
493        Node::ImplBlock { methods, .. } => children.extend(methods.iter()),
494        Node::IfElse {
495            condition,
496            then_body,
497            else_body,
498        } => {
499            children.push(condition.as_ref());
500            children.extend(then_body.iter());
501            if let Some(else_body) = else_body.as_ref() {
502                children.extend(else_body.iter());
503            }
504        }
505        Node::ForIn { iterable, body, .. } => {
506            children.push(iterable.as_ref());
507            children.extend(body.iter());
508        }
509        Node::WhileLoop { condition, body } => {
510            children.push(condition.as_ref());
511            children.extend(body.iter());
512        }
513        Node::MatchExpr { value, arms } => {
514            children.push(value.as_ref());
515            for arm in arms {
516                if let Some(guard) = arm.guard.as_ref() {
517                    children.push(guard.as_ref());
518                }
519                children.extend(arm.body.iter());
520            }
521        }
522        Node::CostRoute { options, body } => {
523            for (_key, value) in options {
524                children.push(value);
525            }
526            children.extend(body.iter());
527        }
528        Node::ReturnStmt { value } => {
529            if let Some(value) = value.as_ref() {
530                children.push(value.as_ref());
531            }
532        }
533        Node::ThrowStmt { value } => children.push(value.as_ref()),
534        Node::TryCatch {
535            body,
536            catch_body,
537            finally_body,
538            ..
539        } => {
540            children.extend(body.iter());
541            children.extend(catch_body.iter());
542            if let Some(finally_body) = finally_body.as_ref() {
543                children.extend(finally_body.iter());
544            }
545        }
546        Node::SkillDecl { fields, .. } => {
547            for (_name, value) in fields {
548                children.push(value);
549            }
550        }
551        Node::EvalPackDecl {
552            fields,
553            body,
554            summarize,
555            ..
556        } => {
557            for (_name, value) in fields {
558                children.push(value);
559            }
560            children.extend(body.iter());
561            if let Some(summarize) = summarize.as_ref() {
562                children.extend(summarize.iter());
563            }
564        }
565        Node::LetBinding { value, .. } | Node::VarBinding { value, .. } => {
566            children.push(value.as_ref());
567        }
568        Node::ConstBinding { value, .. } => {
569            children.push(value.as_ref());
570        }
571        Node::DeadlineBlock { duration, body } => {
572            children.push(duration.as_ref());
573            children.extend(body.iter());
574        }
575        Node::YieldExpr { value } => {
576            if let Some(value) = value.as_ref() {
577                children.push(value.as_ref());
578            }
579        }
580        Node::EmitExpr { value } => children.push(value.as_ref()),
581        Node::GuardStmt {
582            condition,
583            else_body,
584        } => {
585            children.push(condition.as_ref());
586            children.extend(else_body.iter());
587        }
588        Node::RequireStmt { condition, message } => {
589            children.push(condition.as_ref());
590            if let Some(message) = message.as_ref() {
591                children.push(message.as_ref());
592            }
593        }
594        Node::HitlExpr { args, .. } => {
595            for arg in args {
596                children.push(&arg.value);
597            }
598        }
599        Node::Parallel {
600            expr,
601            body,
602            options,
603            ..
604        } => {
605            children.push(expr.as_ref());
606            children.extend(body.iter());
607            for (_key, value) in options {
608                children.push(value);
609            }
610        }
611        Node::SelectExpr {
612            cases,
613            timeout,
614            default_body,
615        } => {
616            for case in cases {
617                children.push(case.channel.as_ref());
618                children.extend(case.body.iter());
619            }
620            if let Some((duration, body)) = timeout.as_ref() {
621                children.push(duration.as_ref());
622                children.extend(body.iter());
623            }
624            if let Some(body) = default_body.as_ref() {
625                children.extend(body.iter());
626            }
627        }
628        Node::FunctionCall { args, .. } => children.extend(args.iter()),
629        Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
630            children.push(object.as_ref());
631            children.extend(args.iter());
632        }
633        Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
634            children.push(object.as_ref());
635        }
636        Node::SubscriptAccess { object, index }
637        | Node::OptionalSubscriptAccess { object, index } => {
638            children.push(object.as_ref());
639            children.push(index.as_ref());
640        }
641        Node::SliceAccess { object, start, end } => {
642            children.push(object.as_ref());
643            if let Some(start) = start.as_ref() {
644                children.push(start.as_ref());
645            }
646            if let Some(end) = end.as_ref() {
647                children.push(end.as_ref());
648            }
649        }
650        Node::BinaryOp { left, right, .. } => {
651            children.push(left.as_ref());
652            children.push(right.as_ref());
653        }
654        Node::UnaryOp { operand, .. } => children.push(operand.as_ref()),
655        Node::Ternary {
656            condition,
657            true_expr,
658            false_expr,
659        } => {
660            children.push(condition.as_ref());
661            children.push(true_expr.as_ref());
662            children.push(false_expr.as_ref());
663        }
664        Node::Assignment { target, value, .. } => {
665            children.push(target.as_ref());
666            children.push(value.as_ref());
667        }
668        Node::EnumConstruct { args, .. } => children.extend(args.iter()),
669        Node::StructConstruct { fields, .. } => {
670            for entry in fields {
671                children.push(&entry.key);
672                children.push(&entry.value);
673            }
674        }
675        Node::ListLiteral(items) => children.extend(items.iter()),
676        Node::DictLiteral(entries) => {
677            for entry in entries {
678                children.push(&entry.key);
679                children.push(&entry.value);
680            }
681        }
682        Node::Spread(inner) => children.push(inner.as_ref()),
683        Node::TryOperator { operand } | Node::TryStar { operand } => {
684            children.push(operand.as_ref());
685        }
686        Node::OrPattern(items) => children.extend(items.iter()),
687        Node::Closure { body, .. } => children.extend(body.iter()),
688        Node::RangeExpr { start, end, .. } => {
689            children.push(start.as_ref());
690            children.push(end.as_ref());
691        }
692        _ => {}
693    }
694    children
695}
696
697fn effect_allowed_by_ceiling(effect: &EffectRecord, ceiling: &CapabilityPolicy) -> bool {
698    if !ceiling.capabilities.is_empty() {
699        let (capability, op) = effect_capability_op(effect);
700        let allowed = ceiling
701            .capabilities
702            .get(capability)
703            .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op));
704        if !allowed {
705            return false;
706        }
707    }
708    if let Some(ceiling_level) = ceiling.side_effect_level.as_deref() {
709        let requested = side_effect_level_for(effect);
710        if requested_exceeds_ceiling(requested, ceiling_level) {
711            return false;
712        }
713    }
714    true
715}
716
717fn effect_capability_op(effect: &EffectRecord) -> (&'static str, &'static str) {
718    match (&effect.kind, effect.scope) {
719        (EffectKind::Stdio, EffectScope::Read) => ("stdio", "read"),
720        (EffectKind::Stdio, _) => ("stdio", "write"),
721        (EffectKind::Fs, EffectScope::Read) => ("workspace", "read_text"),
722        (EffectKind::Fs, EffectScope::Write) => ("workspace", "write_text"),
723        (EffectKind::Fs, EffectScope::Mutate) => ("workspace", "apply_edit"),
724        (EffectKind::Fs, EffectScope::Observe) => ("workspace", "exists"),
725        (EffectKind::Net, _) => ("network", "http"),
726        (EffectKind::Llm { .. }, EffectScope::Read) => ("llm", "catalog"),
727        (EffectKind::Llm { .. }, _) => ("llm", "call"),
728        (EffectKind::Tool { .. }, _) => ("host", "tool_call"),
729        (EffectKind::Hostcall { .. }, _) => ("connector", "call"),
730        (EffectKind::Persona { .. }, _) => ("worker", "dispatch"),
731        (EffectKind::Spawn, _) => ("worker", "dispatch"),
732    }
733}
734
735fn side_effect_level_for(effect: &EffectRecord) -> &'static str {
736    match (&effect.kind, effect.scope) {
737        (EffectKind::Stdio, _) => "read_only",
738        (EffectKind::Fs, EffectScope::Read | EffectScope::Observe) => "read_only",
739        (EffectKind::Fs, _) => "workspace_write",
740        (EffectKind::Net, _) => "network",
741        (EffectKind::Llm { .. }, EffectScope::Read) => "read_only",
742        (EffectKind::Llm { .. }, _) => "network",
743        (EffectKind::Tool { .. }, _) => "workspace_write",
744        (EffectKind::Hostcall { name }, _) if name.starts_with("process.") => "process_exec",
745        (EffectKind::Hostcall { .. }, _) => "read_only",
746        (EffectKind::Persona { .. }, _) => "workspace_write",
747        (EffectKind::Spawn, _) => "workspace_write",
748    }
749}
750
751fn requested_exceeds_ceiling(requested: &str, ceiling: &str) -> bool {
752    fn rank(value: &str) -> usize {
753        match value {
754            "none" => 0,
755            "read_only" => 1,
756            "workspace_write" => 2,
757            "process_exec" => 3,
758            "network" => 4,
759            _ => 5,
760        }
761    }
762    rank(requested) > rank(ceiling)
763}
764
765/// Round-trip a typed effect list through the `metadata` map a child
766/// spawn-config carries. Pipelines that pre-compute effects can stash
767/// them under `effects` and the spawn shim lifts them onto the handoff.
768pub fn effects_from_metadata(metadata: &BTreeMap<String, serde_json::Value>) -> Vec<EffectRecord> {
769    metadata
770        .get("effects")
771        .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
772        .unwrap_or_default()
773}
774
775/// Decide whether `child` is covered by `parent`. An effect is covered
776/// when the parent declares another record with the same kind family
777/// and a scope that is at least as permissive. `resource` is treated
778/// best-effort: when the parent carries a non-empty resource it must
779/// match the child's resource exactly (and the child's resource must be
780/// known); when the parent has no resource it covers any resource the
781/// child names. This is the core of E5.4's `HARN-CAP-301` enforcement —
782/// the dispatcher and the static analyzer share one implementation so
783/// preflight and runtime never disagree.
784fn parent_covers_child(parent: &EffectRecord, child: &EffectRecord) -> bool {
785    if !effect_kind_family_matches(&parent.kind, &child.kind) {
786        return false;
787    }
788    if !effect_scope_covers(parent.scope, child.scope) {
789        return false;
790    }
791    match (parent.resource.as_deref(), child.resource.as_deref()) {
792        (Some(""), _) => true,
793        (Some(parent_resource), Some(child_resource)) => parent_resource == child_resource,
794        (Some(_), None) => false,
795        (None, _) => true,
796    }
797}
798
799fn effect_kind_family_matches(parent: &EffectKind, child: &EffectKind) -> bool {
800    match (parent, child) {
801        (EffectKind::Stdio, EffectKind::Stdio)
802        | (EffectKind::Fs, EffectKind::Fs)
803        | (EffectKind::Net, EffectKind::Net)
804        | (EffectKind::Spawn, EffectKind::Spawn) => true,
805        (EffectKind::Llm { .. }, EffectKind::Llm { .. }) => true,
806        (
807            EffectKind::Tool {
808                name: parent_name, ..
809            },
810            EffectKind::Tool {
811                name: child_name, ..
812            },
813        ) => parent_name.is_empty() || parent_name == child_name,
814        (
815            EffectKind::Hostcall {
816                name: parent_name, ..
817            },
818            EffectKind::Hostcall {
819                name: child_name, ..
820            },
821        ) => parent_name.is_empty() || parent_name == child_name,
822        (EffectKind::Persona { id: parent_id }, EffectKind::Persona { id: child_id }) => {
823            parent_id.is_empty() || parent_id == child_id
824        }
825        _ => false,
826    }
827}
828
829fn effect_scope_covers(parent: EffectScope, child: EffectScope) -> bool {
830    fn rank(scope: EffectScope) -> u8 {
831        match scope {
832            EffectScope::Read => 1,
833            EffectScope::Observe => 1,
834            EffectScope::Write => 2,
835            EffectScope::Mutate => 3,
836        }
837    }
838    rank(parent) >= rank(child)
839}
840
841/// Compute the subset of `child` effects that are not covered by any
842/// record in `parent`. An empty parent set is treated as "no declared
843/// effects" — under E5.4 the dispatcher takes that to mean every child
844/// effect is a violation, because a child can never out-grant an
845/// undeclared parent. When `parent` is `None` enforcement is skipped
846/// entirely (the caller has decided no static ceiling applies).
847pub fn effect_subset_violations(
848    parent: Option<&[EffectRecord]>,
849    child: &[EffectRecord],
850) -> Vec<EffectRecord> {
851    let Some(parent) = parent else {
852        return Vec::new();
853    };
854    child
855        .iter()
856        .filter(|effect| {
857            !parent
858                .iter()
859                .any(|allowed| parent_covers_child(allowed, effect))
860        })
861        .cloned()
862        .collect()
863}
864
865/// Short human-readable label for `effect.kind` used in
866/// `EffectInheritanceViolation` messages and `HARN-CAP-301` diagnostics.
867pub fn effect_kind_label(kind: &EffectKind) -> String {
868    match kind {
869        EffectKind::Stdio => "stdio".to_string(),
870        EffectKind::Fs => "fs".to_string(),
871        EffectKind::Net => "net".to_string(),
872        EffectKind::Llm { provider, model } => match (provider.as_deref(), model.as_deref()) {
873            (Some(provider), Some(model)) => format!("llm:{provider}/{model}"),
874            (Some(provider), None) => format!("llm:{provider}"),
875            (None, Some(model)) => format!("llm:{model}"),
876            (None, None) => "llm".to_string(),
877        },
878        EffectKind::Tool { name } if !name.is_empty() => format!("tool:{name}"),
879        EffectKind::Tool { .. } => "tool".to_string(),
880        EffectKind::Hostcall { name } if !name.is_empty() => format!("hostcall:{name}"),
881        EffectKind::Hostcall { .. } => "hostcall".to_string(),
882        EffectKind::Persona { id } if !id.is_empty() => format!("persona:{id}"),
883        EffectKind::Persona { .. } => "persona".to_string(),
884        EffectKind::Spawn => "spawn".to_string(),
885    }
886}
887
888/// One-line summary suitable for diagnostic messages and deny events.
889pub fn effect_record_summary(effect: &EffectRecord) -> String {
890    let scope = match effect.scope {
891        EffectScope::Read => "read",
892        EffectScope::Write => "write",
893        EffectScope::Mutate => "mutate",
894        EffectScope::Observe => "observe",
895    };
896    match effect.resource.as_deref() {
897        Some(resource) if !resource.is_empty() => {
898            format!(
899                "{}:{} ({})",
900                effect_kind_label(&effect.kind),
901                scope,
902                resource
903            )
904        }
905        _ => format!("{}:{}", effect_kind_label(&effect.kind), scope),
906    }
907}
908
909#[cfg(test)]
910mod tests {
911    use super::*;
912
913    #[test]
914    fn harness_net_call_yields_net_effect() {
915        let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test") }"#;
916        let effects = compute_handoff_effects(source, None);
917        assert!(
918            effects
919                .iter()
920                .any(|effect| matches!(effect.kind, EffectKind::Net)
921                    && effect.scope == EffectScope::Write),
922            "expected Net write effect, got {effects:?}"
923        );
924    }
925
926    #[test]
927    fn harness_process_spawn_captured_yields_process_hostcall_effect() {
928        let source =
929            r#"fn main(harness: Harness) { harness.process.spawn_captured({cmd: "printf"}) }"#;
930        let effects = compute_handoff_effects(source, None);
931        assert!(
932            effects.iter().any(|effect| {
933                matches!(
934                    &effect.kind,
935                    EffectKind::Hostcall { name } if name == "process.spawn_captured"
936                ) && effect.scope == EffectScope::Write
937            }),
938            "expected process hostcall write effect, got {effects:?}"
939        );
940    }
941
942    #[test]
943    fn http_get_builtin_yields_net_effect_with_resource() {
944        let source = r#"fn main() { http_get("https://example.test/api") }"#;
945        let effects = compute_handoff_effects(source, None);
946        let net = effects
947            .iter()
948            .find(|effect| matches!(effect.kind, EffectKind::Net))
949            .expect("net effect");
950        assert_eq!(net.scope, EffectScope::Write);
951        assert_eq!(net.resource.as_deref(), Some("https://example.test/api"));
952    }
953
954    #[test]
955    fn harness_fs_write_yields_fs_write_effect() {
956        let source = r#"fn main(harness: Harness) { harness.fs.write_file("/tmp/out", "hi") }"#;
957        let effects = compute_handoff_effects(source, None);
958        assert!(
959            effects
960                .iter()
961                .any(|effect| matches!(effect.kind, EffectKind::Fs)
962                    && effect.scope == EffectScope::Write),
963            "expected Fs write effect, got {effects:?}"
964        );
965    }
966
967    #[test]
968    fn harness_term_read_password_yields_stdio_read_effect() {
969        let source = r#"fn main(harness: Harness) { harness.term.read_password("password: ") }"#;
970        let effects = compute_handoff_effects(source, None);
971        assert!(
972            effects
973                .iter()
974                .any(|effect| matches!(effect.kind, EffectKind::Stdio)
975                    && effect.scope == EffectScope::Read),
976            "expected Stdio read effect, got {effects:?}"
977        );
978    }
979
980    #[test]
981    fn harness_fs_mkdtemp_yields_fs_write_effect() {
982        let source = r#"fn main(harness: Harness) { harness.fs.mkdtemp("harn-") }"#;
983        let effects = compute_handoff_effects(source, None);
984        assert!(
985            effects
986                .iter()
987                .any(|effect| matches!(effect.kind, EffectKind::Fs)
988                    && effect.scope == EffectScope::Write),
989            "expected Fs write effect, got {effects:?}"
990        );
991    }
992
993    #[test]
994    fn harness_crypto_sha256_is_pure_for_handoff_effects() {
995        let source = r#"fn main(harness: Harness) { harness.crypto.sha256("hello") }"#;
996        let effects = compute_handoff_effects(source, None);
997        assert!(effects.is_empty(), "expected no effects, got {effects:?}");
998    }
999
1000    #[test]
1001    fn harness_stdio_read_line_yields_stdio_read_effect() {
1002        let source = r#"fn main(harness: Harness) { harness.stdio.read_line() }"#;
1003        let effects = compute_handoff_effects(source, None);
1004        assert!(
1005            effects
1006                .iter()
1007                .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1008                    && effect.scope == EffectScope::Read),
1009            "expected Stdio read effect, got {effects:?}"
1010        );
1011    }
1012
1013    #[test]
1014    fn llm_call_emits_llm_effect_with_provider_and_model() {
1015        let source = r#"fn main() {
1016            llm_call("summarize", { provider: "anthropic", model: "claude-3-5-sonnet" })
1017        }"#;
1018        let effects = compute_handoff_effects(source, None);
1019        let llm = effects
1020            .iter()
1021            .find(|effect| matches!(effect.kind, EffectKind::Llm { .. }))
1022            .expect("llm effect");
1023        let EffectKind::Llm { provider, model } = &llm.kind else {
1024            panic!("expected llm kind, got {:?}", llm.kind);
1025        };
1026        assert_eq!(provider.as_deref(), Some("anthropic"));
1027        assert_eq!(model.as_deref(), Some("claude-3-5-sonnet"));
1028    }
1029
1030    #[test]
1031    fn harness_llm_catalog_yields_read_effect() {
1032        let source = r#"fn main(harness: Harness) {
1033            harness.llm.catalog()
1034            harness.llm.providers()
1035        }"#;
1036        let effects = compute_handoff_effects(source, None);
1037        assert!(
1038            effects
1039                .iter()
1040                .any(|effect| matches!(effect.kind, EffectKind::Llm { .. })
1041                    && effect.scope == EffectScope::Read),
1042            "expected LLM read effect, got {effects:?}"
1043        );
1044    }
1045
1046    #[test]
1047    fn ceiling_drops_disallowed_capabilities() {
1048        let source = r#"fn main(harness: Harness) {
1049            harness.net.get("https://example.test")
1050            harness.fs.read_file("/tmp/in")
1051        }"#;
1052        let mut ceiling = CapabilityPolicy::default();
1053        ceiling
1054            .capabilities
1055            .insert("workspace".to_string(), vec!["read_text".to_string()]);
1056        let effects = compute_handoff_effects(source, Some(&ceiling));
1057        assert!(
1058            effects
1059                .iter()
1060                .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1061            "ceiling without `network` should drop Net effect, got {effects:?}"
1062        );
1063        assert!(
1064            effects
1065                .iter()
1066                .any(|effect| matches!(effect.kind, EffectKind::Fs)),
1067            "ceiling with workspace.read_text should keep Fs read, got {effects:?}"
1068        );
1069    }
1070
1071    #[test]
1072    fn ceiling_side_effect_level_clamps_writes() {
1073        let source = r#"fn main(harness: Harness) {
1074            harness.net.get("https://example.test")
1075            __io_println("hi")
1076        }"#;
1077        let ceiling = CapabilityPolicy {
1078            side_effect_level: Some("read_only".to_string()),
1079            ..Default::default()
1080        };
1081        let effects = compute_handoff_effects(source, Some(&ceiling));
1082        assert!(
1083            effects
1084                .iter()
1085                .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1086            "read_only ceiling must drop Net write, got {effects:?}"
1087        );
1088        assert!(
1089            effects
1090                .iter()
1091                .any(|effect| matches!(effect.kind, EffectKind::Stdio)),
1092            "stdio observe should pass read_only ceiling, got {effects:?}"
1093        );
1094    }
1095
1096    #[test]
1097    fn effect_record_round_trips_through_serde() {
1098        let effects = vec![
1099            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1100                .with_resource("https://api.example/v1"),
1101            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1102            EffectRecord::new(
1103                EffectKind::Llm {
1104                    provider: Some("anthropic".to_string()),
1105                    model: Some("claude-3-7-sonnet".to_string()),
1106                },
1107                EffectScope::Write,
1108            ),
1109            EffectRecord::new(
1110                EffectKind::Tool {
1111                    name: "search".to_string(),
1112                },
1113                EffectScope::Read,
1114            ),
1115        ];
1116        let encoded = serde_json::to_string(&effects).expect("encode");
1117        let decoded: Vec<EffectRecord> = serde_json::from_str(&encoded).expect("decode");
1118        assert_eq!(decoded, effects);
1119    }
1120
1121    #[test]
1122    fn empty_source_returns_no_effects() {
1123        let effects = compute_handoff_effects("fn main() {}", None);
1124        assert!(effects.is_empty(), "got {effects:?}");
1125    }
1126
1127    #[test]
1128    fn effects_from_metadata_round_trips_typed_payload() {
1129        let effects = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1130            .with_resource("https://api.example")];
1131        let mut metadata: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1132        metadata.insert(
1133            "effects".to_string(),
1134            serde_json::to_value(&effects).expect("encode"),
1135        );
1136        assert_eq!(effects_from_metadata(&metadata), effects);
1137    }
1138
1139    #[test]
1140    fn subset_violations_returns_empty_when_child_covered() {
1141        let parent = vec![
1142            EffectRecord::new(EffectKind::Net, EffectScope::Write),
1143            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1144        ];
1145        let child = vec![
1146            EffectRecord::new(EffectKind::Net, EffectScope::Write)
1147                .with_resource("https://example.test"),
1148            EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1149        ];
1150        assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1151    }
1152
1153    #[test]
1154    fn subset_violations_flags_unmatched_kinds() {
1155        let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1156        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1157            .with_resource("https://example.test")];
1158        let violations = effect_subset_violations(Some(&parent), &child);
1159        assert_eq!(violations.len(), 1);
1160        assert!(matches!(violations[0].kind, EffectKind::Net));
1161    }
1162
1163    #[test]
1164    fn subset_violations_flags_scope_escalations() {
1165        let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1166        let child = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)];
1167        let violations = effect_subset_violations(Some(&parent), &child);
1168        assert_eq!(violations.len(), 1);
1169        assert_eq!(violations[0].scope, EffectScope::Mutate);
1170    }
1171
1172    #[test]
1173    fn subset_violations_treats_missing_parent_resource_as_wildcard() {
1174        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1175        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1176            .with_resource("https://api.example/v1")];
1177        assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1178    }
1179
1180    #[test]
1181    fn subset_violations_requires_resource_match_when_parent_declares_one() {
1182        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1183            .with_resource("https://allowed.test")];
1184        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1185            .with_resource("https://disallowed.test")];
1186        let violations = effect_subset_violations(Some(&parent), &child);
1187        assert_eq!(violations.len(), 1);
1188    }
1189
1190    #[test]
1191    fn subset_violations_skip_when_parent_is_none() {
1192        let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1193        assert!(effect_subset_violations(None, &child).is_empty());
1194    }
1195
1196    #[test]
1197    fn subset_violations_empty_parent_flags_every_child_effect() {
1198        let parent: Vec<EffectRecord> = Vec::new();
1199        let child = vec![
1200            EffectRecord::new(EffectKind::Net, EffectScope::Write),
1201            EffectRecord::new(EffectKind::Fs, EffectScope::Read),
1202        ];
1203        let violations = effect_subset_violations(Some(&parent), &child);
1204        assert_eq!(violations.len(), 2);
1205    }
1206
1207    #[test]
1208    fn subset_violations_empty_child_is_always_allowed() {
1209        let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1210        assert!(effect_subset_violations(Some(&parent), &[]).is_empty());
1211    }
1212
1213    #[test]
1214    fn effect_kind_label_shape() {
1215        assert_eq!(effect_kind_label(&EffectKind::Net), "net");
1216        assert_eq!(
1217            effect_kind_label(&EffectKind::Llm {
1218                provider: Some("anthropic".to_string()),
1219                model: Some("claude-3-7-sonnet".to_string()),
1220            }),
1221            "llm:anthropic/claude-3-7-sonnet"
1222        );
1223        assert_eq!(
1224            effect_kind_label(&EffectKind::Tool {
1225                name: "search".to_string()
1226            }),
1227            "tool:search"
1228        );
1229    }
1230
1231    #[test]
1232    fn effect_record_summary_includes_resource() {
1233        let effect = EffectRecord::new(EffectKind::Net, EffectScope::Write)
1234            .with_resource("https://example.test/api");
1235        assert_eq!(
1236            effect_record_summary(&effect),
1237            "net:write (https://example.test/api)"
1238        );
1239    }
1240
1241    #[test]
1242    fn deduplicates_repeated_effects() {
1243        let source = r#"fn main() {
1244            http_get("https://example.test")
1245            http_get("https://example.test")
1246            http_get("https://example.test")
1247        }"#;
1248        let effects = compute_handoff_effects(source, None);
1249        let net_count = effects
1250            .iter()
1251            .filter(|effect| matches!(effect.kind, EffectKind::Net))
1252            .count();
1253        assert_eq!(net_count, 1, "expected dedup, got {effects:?}");
1254    }
1255}