1use std::collections::{BTreeMap, BTreeSet, HashSet};
16
17use serde::{Deserialize, Serialize};
18
19use harn_ir::{CallClassification, Capability, LiteralValue, NodeSemantics};
20use harn_parser::{Node, SNode};
21
22use super::effect_call_cache::resolve_runtime_resources;
23use super::CapabilityPolicy;
24use crate::VmValue;
25
26#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
29#[serde(tag = "kind", rename_all = "snake_case")]
30pub enum EffectKind {
31 Stdio,
33 Fs,
35 Net,
37 Env,
39 Clock,
41 Random,
43 Process,
45 Secret,
47 Observability,
49 Channel,
51 State,
53 Host,
55 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 Tool { name: String },
65 Hostcall { name: String },
67 Persona { id: String },
69 Spawn,
71}
72
73#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
76#[serde(rename_all = "snake_case")]
77pub enum EffectScope {
78 Read,
80 Write,
82 Mutate,
84 Observe,
86}
87
88#[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#[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
148pub 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 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 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 if let CallClassification::Capabilities(capability_effects) = &call.classification {
203 let contract = call
204 .name
205 .strip_prefix("harness.")
206 .and_then(|path| path.split_once('.'))
207 .and_then(|(field, method)| {
208 let capability = harn_builtin_meta::CapabilityId::from_field_name(field)?;
209 crate::stdlib::capability_method_manifest_entry(capability, method)
210 })
211 .or_else(|| crate::stdlib::builtin_manifest_entry(&call.name));
212 if let Some(entry) = contract {
213 return effect_specs_to_records(entry.contract.effects, &call.literal_args);
214 }
215 return capability_effects
216 .iter()
217 .filter_map(capability_effect_to_record)
218 .collect();
219 }
220 Vec::new()
221}
222
223pub(crate) fn runtime_effects_from_contract(
224 specs: &[harn_builtin_meta::EffectSpec],
225 args: &[VmValue],
226) -> Vec<EffectRecord> {
227 let mut records = Vec::new();
228 let llm_specs = specs
229 .iter()
230 .filter(|spec| spec.kind == harn_builtin_meta::EffectKind::Llm)
231 .collect::<Vec<_>>();
232 if let Some(first) = llm_specs.first() {
233 let mut provider = None;
234 let mut model = None;
235 for spec in &llm_specs {
236 for selector in spec.resources {
237 let harn_builtin_meta::ResourceSelector::Field { path, .. } = selector else {
238 continue;
239 };
240 let value = resolve_runtime_resources(*selector, args)
241 .into_iter()
242 .next();
243 match path.last().copied() {
244 Some("provider") => provider = value.map(|value| value.to_string()),
245 Some("model") => model = value.map(|value| value.to_string()),
246 _ => {}
247 }
248 }
249 }
250 records.push(EffectRecord::new(
251 EffectKind::Llm { provider, model },
252 effect_scope_from_contract(first.access),
253 ));
254 }
255 for spec in specs {
256 if spec.kind == harn_builtin_meta::EffectKind::Llm {
257 continue;
258 }
259 let kind = effect_kind_from_contract(spec.kind);
260 let scope = effect_scope_from_contract(spec.access);
261 let resources = spec
262 .resources
263 .iter()
264 .flat_map(|selector| resolve_runtime_resources(*selector, args))
265 .collect::<Vec<_>>();
266 if resources.is_empty() {
267 records.push(EffectRecord::new(kind, scope));
268 } else {
269 records.extend(
270 resources
271 .into_iter()
272 .map(|resource| EffectRecord::new(kind.clone(), scope).with_resource(resource)),
273 );
274 }
275 }
276 records
277}
278
279fn effect_kind_from_contract(kind: harn_builtin_meta::EffectKind) -> EffectKind {
280 use harn_builtin_meta::EffectKind as ContractKind;
281 match kind {
282 ContractKind::Stdio => EffectKind::Stdio,
283 ContractKind::Fs => EffectKind::Fs,
284 ContractKind::Network => EffectKind::Net,
285 ContractKind::Llm => EffectKind::Llm {
286 provider: None,
287 model: None,
288 },
289 ContractKind::Tool => EffectKind::Tool {
290 name: String::new(),
291 },
292 ContractKind::Mcp => EffectKind::Tool {
293 name: "mcp".to_string(),
294 },
295 ContractKind::Worker => EffectKind::Spawn,
296 ContractKind::Process => EffectKind::Process,
297 ContractKind::Env => EffectKind::Env,
298 ContractKind::Clock => EffectKind::Clock,
299 ContractKind::Random => EffectKind::Random,
300 ContractKind::Host => EffectKind::Host,
301 ContractKind::Secret => EffectKind::Secret,
302 ContractKind::Observability => EffectKind::Observability,
303 ContractKind::Channel => EffectKind::Channel,
304 ContractKind::State => EffectKind::State,
305 }
306}
307
308fn effect_scope_from_contract(access: harn_builtin_meta::EffectAccess) -> EffectScope {
309 match access {
310 harn_builtin_meta::EffectAccess::Read => EffectScope::Read,
311 harn_builtin_meta::EffectAccess::Write => EffectScope::Write,
312 harn_builtin_meta::EffectAccess::Mutate => EffectScope::Mutate,
313 harn_builtin_meta::EffectAccess::Observe => EffectScope::Observe,
314 }
315}
316
317fn resolve_contract_resources(
318 selector: harn_builtin_meta::ResourceSelector,
319 args: &[LiteralValue],
320) -> Vec<String> {
321 use harn_builtin_meta::ResourceSelector;
322 match selector {
323 ResourceSelector::Argument(index) => args
324 .get(index as usize)
325 .and_then(LiteralValue::as_str)
326 .map(|value| vec![value.to_string()])
327 .unwrap_or_default(),
328 ResourceSelector::Field { argument, path } => {
329 let mut value = args.get(argument as usize);
330 for field in path {
331 value = value.and_then(|value| value.dict_field(field));
332 }
333 value
334 .and_then(LiteralValue::as_str)
335 .map(|value| vec![value.to_string()])
336 .unwrap_or_default()
337 }
338 ResourceSelector::EachArgument(index) => args
339 .get(index as usize)
340 .and_then(LiteralValue::list_items)
341 .into_iter()
342 .flatten()
343 .filter_map(LiteralValue::as_str)
344 .map(str::to_string)
345 .collect(),
346 ResourceSelector::Constant(value) => vec![value.to_string()],
347 ResourceSelector::Dynamic => Vec::new(),
348 }
349}
350
351fn effect_specs_to_records(
352 specs: &[harn_builtin_meta::EffectSpec],
353 args: &[LiteralValue],
354) -> Vec<EffectRecord> {
355 let mut records = Vec::new();
356 let llm_specs = specs
357 .iter()
358 .filter(|spec| spec.kind == harn_builtin_meta::EffectKind::Llm)
359 .collect::<Vec<_>>();
360 if let Some(first) = llm_specs.first() {
361 let mut provider = None;
362 let mut model = None;
363 for spec in &llm_specs {
364 for selector in spec.resources {
365 let harn_builtin_meta::ResourceSelector::Field { path, .. } = selector else {
366 continue;
367 };
368 let value = resolve_contract_resources(*selector, args)
369 .into_iter()
370 .next();
371 match path.last().copied() {
372 Some("provider") => provider = value,
373 Some("model") => model = value,
374 _ => {}
375 }
376 }
377 }
378 records.push(EffectRecord::new(
379 EffectKind::Llm { provider, model },
380 effect_scope_from_contract(first.access),
381 ));
382 }
383 for spec in specs {
384 if spec.kind == harn_builtin_meta::EffectKind::Llm {
385 continue;
386 }
387 let kind = effect_kind_from_contract(spec.kind);
388 let scope = effect_scope_from_contract(spec.access);
389 let resources = spec
390 .resources
391 .iter()
392 .flat_map(|selector| resolve_contract_resources(*selector, args))
393 .collect::<Vec<_>>();
394 if resources.is_empty() {
395 records.push(EffectRecord::new(kind, scope));
396 } else {
397 records.extend(
398 resources
399 .into_iter()
400 .map(|resource| EffectRecord::new(kind.clone(), scope).with_resource(resource)),
401 );
402 }
403 }
404 records
405}
406
407fn builtin_effect(name: &str) -> Option<EffectRecord> {
408 match name {
409 "print" | "println" | "eprint" | "eprintln" | "write_stdout" | "write_stderr"
411 | "__io_print" | "__io_println" | "__io_eprint" | "__io_eprintln" | "__io_write_stdout"
412 | "__io_write_stderr" => Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Observe)),
413 "read_line" | "read_stdin" | "prompt_user" | "__io_read_line" => {
414 Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Read))
415 }
416
417 "read_file"
419 | "read_file_bytes"
420 | "read_file_result"
421 | "package_snapshot_open"
422 | "render"
423 | "render_prompt"
424 | "render_with_provenance"
425 | "find_text"
426 | "find_evidence"
427 | "read_lines"
428 | "list_dir"
429 | "walk_dir"
430 | "glob"
431 | "file_exists"
432 | "path_status"
433 | "stat" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Read)),
434
435 "write_file"
437 | "write_file_bytes"
438 | "replace_file"
439 | "replace_file_result"
440 | "replace_file_bytes"
441 | "replace_file_bytes_result"
442 | "append_file"
443 | "append_file_locked"
444 | "mkdir"
445 | "mkdtemp"
446 | "mkdtemp_in_workspace"
447 | "copy_file"
448 | "move_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Write)),
449 "delete_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
450 "apply_edit" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
451
452 "http_get"
456 | "http_post"
457 | "http_put"
458 | "http_patch"
459 | "http_delete"
460 | "http_request"
461 | "http_download"
462 | "http_session"
463 | "http_session_request"
464 | "http_session_close"
465 | "http_stream_open"
466 | "http_stream_read"
467 | "http_stream_close"
468 | "http_stream_info"
469 | "sse_connect"
470 | "sse_receive"
471 | "sse_close"
472 | "sse_server_response"
473 | "sse_server_send"
474 | "sse_server_heartbeat"
475 | "sse_server_flush"
476 | "sse_server_close"
477 | "sse_server_cancel"
478 | "websocket_connect"
479 | "websocket_accept"
480 | "websocket_send"
481 | "websocket_receive"
482 | "websocket_close"
483 | "websocket_route"
484 | "websocket_server"
485 | "websocket_server_close"
486 | "unix_socket_json_request"
487 | "__net_unix_socket_json_request" => {
488 Some(EffectRecord::new(EffectKind::Net, EffectScope::Write))
489 }
490
491 "llm_call"
493 | "llm_call_safe"
494 | "llm_stream_call"
495 | "llm_call_structured"
496 | "llm_call_structured_safe"
497 | "llm_call_structured_result"
498 | "llm_completion"
499 | "agent_llm_turn"
500 | "agent_turn"
501 | "agent_loop" => Some(EffectRecord::new(
502 EffectKind::Llm {
503 provider: None,
504 model: None,
505 },
506 EffectScope::Write,
507 )),
508 "llm_catalog" | "llm_provider_status" => Some(EffectRecord::new(
509 EffectKind::Llm {
510 provider: None,
511 model: None,
512 },
513 EffectScope::Read,
514 )),
515 "llm_catalog_refresh" => Some(EffectRecord::new(
516 EffectKind::Llm {
517 provider: None,
518 model: None,
519 },
520 EffectScope::Write,
521 )),
522
523 "spawn_agent"
525 | "send_input"
526 | "resume_agent"
527 | "wait_agent"
528 | "close_agent"
529 | "worker_trigger"
530 | "__host_sub_agent_run"
531 | "__host_worker_spawn"
532 | "__host_worker_send_input"
533 | "__host_worker_resume"
534 | "__host_worker_trigger"
535 | "__host_worker_wait"
536 | "__host_worker_close" => Some(EffectRecord::new(EffectKind::Spawn, EffectScope::Write)),
537
538 "tool_call" | "host_tool_call" => Some(EffectRecord::new(
540 EffectKind::Tool {
541 name: String::new(),
542 },
543 EffectScope::Write,
544 )),
545
546 _ => None,
547 }
548}
549
550pub(super) fn builtin_has_network_effect(name: &str) -> bool {
551 if matches!(name, "__files_upload" | "upload") {
552 return true;
553 }
554 builtin_effect(name).is_some_and(|effect| matches!(effect.kind, EffectKind::Net))
555}
556
557fn capability_effect_to_record(effect: &harn_ir::CapabilityEffect) -> Option<EffectRecord> {
558 let contract_scope = match effect.access {
559 harn_builtin_meta::EffectAccess::Read => EffectScope::Read,
560 harn_builtin_meta::EffectAccess::Write => EffectScope::Write,
561 harn_builtin_meta::EffectAccess::Mutate => EffectScope::Mutate,
562 harn_builtin_meta::EffectAccess::Observe => EffectScope::Observe,
563 };
564 let (kind, scope) = match effect.capability {
565 Capability::FilesystemRead => (EffectKind::Fs, contract_scope),
566 Capability::WorkspaceMutation => (EffectKind::Fs, EffectScope::Mutate),
567 Capability::CommandExecution => (
568 EffectKind::Hostcall {
569 name: format!("process.{}", effect.operation),
570 },
571 EffectScope::Write,
572 ),
573 Capability::NetworkAccess => (EffectKind::Net, contract_scope),
574 Capability::ConnectorAccess => (
575 EffectKind::Hostcall {
576 name: if effect.operation.is_empty() {
577 "connector.call".to_string()
578 } else {
579 format!("connector.{}", effect.operation)
580 },
581 },
582 EffectScope::Write,
583 ),
584 Capability::ModelCall => (
585 EffectKind::Llm {
586 provider: None,
587 model: None,
588 },
589 contract_scope,
590 ),
591 Capability::WorkerDispatch => (EffectKind::Spawn, EffectScope::Write),
592 Capability::Stdio => (EffectKind::Stdio, contract_scope),
593 Capability::Environment => (EffectKind::Env, contract_scope),
594 Capability::Clock => (EffectKind::Clock, contract_scope),
595 Capability::Random => (EffectKind::Random, contract_scope),
596 Capability::Secret => (EffectKind::Secret, contract_scope),
597 Capability::Observability => (EffectKind::Observability, contract_scope),
598 Capability::Channel => (EffectKind::Channel, contract_scope),
599 Capability::State => (EffectKind::State, contract_scope),
600 Capability::HumanApproval => return None,
601 Capability::AutonomyPolicy => return None,
602 };
603 let resource = effect.path.as_deref().map(crate::value::HarnStr::from);
604 Some(EffectRecord {
605 kind,
606 scope,
607 resource,
608 })
609}
610
611#[derive(Clone, Default)]
612struct CapabilityBindings {
613 roots: BTreeSet<String>,
614 handles: BTreeMap<String, harn_builtin_meta::CapabilityId>,
615}
616
617fn walk_for_harness_effects(
618 node: &SNode,
619 bindings: &mut CapabilityBindings,
620 out: &mut BTreeSet<EffectRecord>,
621) {
622 match &node.node {
623 Node::FnDecl { params, body, .. }
624 | Node::ToolDecl { params, body, .. }
625 | Node::Pipeline { params, body, .. } => {
626 let mut callable_bindings = bindings.clone();
627 for param in params {
628 let Some(harn_parser::TypeExpr::Named(type_name)) = ¶m.type_expr else {
629 continue;
630 };
631 if type_name == "Harness" {
632 callable_bindings.roots.insert(param.name.clone());
633 } else if let Some(capability) =
634 harn_builtin_meta::CapabilityId::from_type_name(type_name)
635 {
636 callable_bindings
637 .handles
638 .insert(param.name.clone(), capability);
639 }
640 }
641 for statement in body {
642 walk_for_harness_effects(statement, &mut callable_bindings, out);
643 }
644 return;
645 }
646 Node::LetBinding { pattern, value, .. } | Node::ConstBinding { pattern, value, .. } => {
647 if let harn_parser::BindingPattern::Identifier(name) = pattern {
648 if let Some(capability) = capability_value(value, bindings) {
649 bindings.handles.insert(name.clone(), capability);
650 }
651 }
652 }
653 _ => {}
654 }
655 out.extend(harness_method_effects(node, bindings));
656 for child in child_nodes(node) {
657 walk_for_harness_effects(child, bindings, out);
658 }
659}
660
661fn capability_value(
662 node: &SNode,
663 bindings: &CapabilityBindings,
664) -> Option<harn_builtin_meta::CapabilityId> {
665 match &node.node {
666 Node::Identifier(name) => bindings.handles.get(name).copied(),
667 Node::PropertyAccess { object, property }
668 | Node::OptionalPropertyAccess { object, property }
669 if matches!(&object.node, Node::Identifier(root) if bindings.roots.contains(root)) =>
670 {
671 harn_builtin_meta::CapabilityId::from_field_name(property)
672 }
673 _ => None,
674 }
675}
676
677fn harness_method_effects(node: &SNode, bindings: &CapabilityBindings) -> Vec<EffectRecord> {
678 let (object, method, args) = match &node.node {
679 Node::MethodCall {
680 object,
681 method,
682 args,
683 ..
684 }
685 | Node::OptionalMethodCall {
686 object,
687 method,
688 args,
689 ..
690 } => (object, method, args),
691 _ => return Vec::new(),
692 };
693 let capability = capability_value(object, bindings).or_else(|| {
694 let (sub_handle, root) = harness_sub_handle(object)?;
695 matches!(&root.node, Node::Identifier(name) if bindings.roots.contains(name))
696 .then(|| harn_builtin_meta::CapabilityId::from_field_name(&sub_handle))
697 .flatten()
698 });
699 let Some(capability) = capability else {
700 return Vec::new();
701 };
702 let Some(entry) = crate::stdlib::capability_method_manifest_entry(capability, method) else {
703 return Vec::new();
704 };
705 let literal_args = args.iter().map(harn_ir::literal_value).collect::<Vec<_>>();
706 effect_specs_to_records(entry.contract.effects, &literal_args)
707}
708
709fn harness_sub_handle(node: &SNode) -> Option<(String, &SNode)> {
710 match &node.node {
711 Node::PropertyAccess { object, property }
712 | Node::OptionalPropertyAccess { object, property } => {
713 Some((property.clone(), object.as_ref()))
714 }
715 _ => None,
716 }
717}
718
719fn child_nodes(node: &SNode) -> Vec<&SNode> {
720 harn_parser::visit::immediate_children(node)
721}
722
723pub(crate) fn effect_allowed_by_ceiling(effect: &EffectRecord, ceiling: &CapabilityPolicy) -> bool {
724 effect_allowed_by_ceiling_with_authorization(effect, ceiling, false)
725}
726
727pub(crate) fn contract_effect_allowed_by_ceiling(
728 effect: &EffectRecord,
729 contract: harn_builtin_meta::BuiltinContract,
730 ceiling: &CapabilityPolicy,
731) -> bool {
732 let explicitly_authorized = contract.effects_authorized_by.is_some_and(|authority| {
733 super::policy_allows_capability(
734 ceiling,
735 authority.capability.field_name(),
736 authority.operation,
737 )
738 });
739 effect_allowed_by_ceiling_with_authorization(effect, ceiling, explicitly_authorized)
740}
741
742fn effect_allowed_by_ceiling_with_authorization(
743 effect: &EffectRecord,
744 ceiling: &CapabilityPolicy,
745 explicitly_authorized: bool,
746) -> bool {
747 if ceiling.capabilities_are_restricted() {
748 let (capability, op) = effect_capability_op(effect);
749 let allowed = super::policy_allows_capability(ceiling, capability, op);
750 if !allowed && !explicitly_authorized {
751 return false;
752 }
753 }
754 if let Some(ceiling_level) = ceiling.side_effect_level.as_deref() {
755 let requested = side_effect_level_for(effect);
756 if requested_exceeds_ceiling(requested, ceiling_level) {
757 return false;
758 }
759 }
760 true
761}
762
763fn effect_capability_op(effect: &EffectRecord) -> (&'static str, &'static str) {
764 match (&effect.kind, effect.scope) {
765 (EffectKind::Stdio, EffectScope::Read) => ("stdio", "read"),
766 (EffectKind::Stdio, _) => ("stdio", "write"),
767 (EffectKind::Fs, EffectScope::Read) => ("workspace", "read_text"),
768 (EffectKind::Fs, EffectScope::Write) => ("workspace", "write_text"),
769 (EffectKind::Fs, EffectScope::Mutate) => ("workspace", "apply_edit"),
770 (EffectKind::Fs, EffectScope::Observe) => ("workspace", "exists"),
771 (EffectKind::Net, _) => ("network", "http"),
772 (EffectKind::Env, EffectScope::Read | EffectScope::Observe) => ("environment", "read"),
773 (EffectKind::Env, _) => ("environment", "write"),
774 (EffectKind::Clock, _) => ("clock", "now"),
775 (EffectKind::Random, _) => ("random", "bytes"),
776 (EffectKind::Process, EffectScope::Read | EffectScope::Observe) => ("process", "inspect"),
777 (EffectKind::Process, _) => ("process", "run"),
778 (EffectKind::Secret, EffectScope::Read | EffectScope::Observe) => ("secrets", "read"),
779 (EffectKind::Secret, _) => ("secrets", "write"),
780 (EffectKind::Observability, _) => ("observability", "emit"),
781 (EffectKind::Channel, EffectScope::Read | EffectScope::Observe) => ("channel", "read"),
782 (EffectKind::Channel, _) => ("channel", "write"),
783 (EffectKind::State, EffectScope::Read | EffectScope::Observe) => ("state", "read"),
784 (EffectKind::State, _) => ("state", "write"),
785 (EffectKind::Host, _) => ("connector", "call"),
786 (EffectKind::Llm { .. }, EffectScope::Read) => ("llm", "catalog"),
787 (EffectKind::Llm { .. }, _) => ("llm", "call"),
788 (EffectKind::Tool { .. }, _) => ("host", "tool_call"),
789 (EffectKind::Hostcall { .. }, _) => ("connector", "call"),
790 (EffectKind::Persona { .. }, _) => ("worker", "dispatch"),
791 (EffectKind::Spawn, _) => ("worker", "dispatch"),
792 }
793}
794
795fn side_effect_level_for(effect: &EffectRecord) -> &'static str {
796 match (&effect.kind, effect.scope) {
797 (EffectKind::Stdio, _) => "read_only",
798 (EffectKind::Fs, EffectScope::Read | EffectScope::Observe) => "read_only",
799 (EffectKind::Fs, _) => "workspace_write",
800 (EffectKind::Net, _) => "network",
801 (EffectKind::Env, EffectScope::Read | EffectScope::Observe) => "read_only",
802 (EffectKind::Env, _) => "workspace_write",
803 (EffectKind::Clock, _) => "read_only",
804 (EffectKind::Random, _) => "read_only",
805 (EffectKind::Process, EffectScope::Read | EffectScope::Observe) => "read_only",
806 (EffectKind::Process, _) => "process_exec",
807 (EffectKind::Secret, EffectScope::Read | EffectScope::Observe) => "read_only",
808 (EffectKind::Secret, _) => "workspace_write",
809 (EffectKind::Observability, _) => "read_only",
810 (EffectKind::Channel, EffectScope::Read | EffectScope::Observe) => "read_only",
811 (EffectKind::Channel, _) => "workspace_write",
812 (EffectKind::State, EffectScope::Read | EffectScope::Observe) => "read_only",
813 (EffectKind::State, _) => "workspace_write",
814 (EffectKind::Host, EffectScope::Read | EffectScope::Observe) => "read_only",
815 (EffectKind::Host, _) => "workspace_write",
816 (EffectKind::Llm { .. }, _) => "read_only",
822 (EffectKind::Tool { .. }, _) => "workspace_write",
823 (EffectKind::Hostcall { name }, _) if name.starts_with("process.") => "process_exec",
824 (EffectKind::Hostcall { .. }, _) => "read_only",
825 (EffectKind::Persona { .. }, _) => "workspace_write",
826 (EffectKind::Spawn, _) => "workspace_write",
827 }
828}
829
830fn requested_exceeds_ceiling(requested: &str, ceiling: &str) -> bool {
831 fn rank(value: &str) -> usize {
832 crate::tool_annotations::SideEffectLevel::rank_str(value)
833 }
834 rank(requested) > rank(ceiling)
835}
836
837pub fn effects_from_metadata(metadata: &BTreeMap<String, serde_json::Value>) -> Vec<EffectRecord> {
841 metadata
842 .get("effects")
843 .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
844 .unwrap_or_default()
845}
846
847fn parent_covers_child(parent: &EffectRecord, child: &EffectRecord) -> bool {
857 if !effect_kind_family_matches(&parent.kind, &child.kind) {
858 return false;
859 }
860 if !effect_scope_covers(parent.scope, child.scope) {
861 return false;
862 }
863 match (parent.resource.as_deref(), child.resource.as_deref()) {
864 (Some(""), _) => true,
865 (Some(parent_resource), Some(child_resource)) => parent_resource == child_resource,
866 (Some(_), None) => false,
867 (None, _) => true,
868 }
869}
870
871fn effect_kind_family_matches(parent: &EffectKind, child: &EffectKind) -> bool {
872 match (parent, child) {
873 (EffectKind::Stdio, EffectKind::Stdio)
874 | (EffectKind::Fs, EffectKind::Fs)
875 | (EffectKind::Net, EffectKind::Net)
876 | (EffectKind::Env, EffectKind::Env)
877 | (EffectKind::Clock, EffectKind::Clock)
878 | (EffectKind::Random, EffectKind::Random)
879 | (EffectKind::Process, EffectKind::Process)
880 | (EffectKind::Secret, EffectKind::Secret)
881 | (EffectKind::Observability, EffectKind::Observability)
882 | (EffectKind::Channel, EffectKind::Channel)
883 | (EffectKind::State, EffectKind::State)
884 | (EffectKind::Host, EffectKind::Host)
885 | (EffectKind::Spawn, EffectKind::Spawn) => true,
886 (EffectKind::Llm { .. }, EffectKind::Llm { .. }) => true,
887 (
888 EffectKind::Tool {
889 name: parent_name, ..
890 },
891 EffectKind::Tool {
892 name: child_name, ..
893 },
894 ) => parent_name.is_empty() || parent_name == child_name,
895 (
896 EffectKind::Hostcall {
897 name: parent_name, ..
898 },
899 EffectKind::Hostcall {
900 name: child_name, ..
901 },
902 ) => parent_name.is_empty() || parent_name == child_name,
903 (EffectKind::Persona { id: parent_id }, EffectKind::Persona { id: child_id }) => {
904 parent_id.is_empty() || parent_id == child_id
905 }
906 _ => false,
907 }
908}
909
910fn effect_scope_covers(parent: EffectScope, child: EffectScope) -> bool {
911 fn rank(scope: EffectScope) -> u8 {
912 match scope {
913 EffectScope::Read => 1,
914 EffectScope::Observe => 1,
915 EffectScope::Write => 2,
916 EffectScope::Mutate => 3,
917 }
918 }
919 rank(parent) >= rank(child)
920}
921
922pub fn effect_subset_violations(
929 parent: Option<&[EffectRecord]>,
930 child: &[EffectRecord],
931) -> Vec<EffectRecord> {
932 let Some(parent) = parent else {
933 return Vec::new();
934 };
935 child
936 .iter()
937 .filter(|effect| {
938 !parent
939 .iter()
940 .any(|allowed| parent_covers_child(allowed, effect))
941 })
942 .cloned()
943 .collect()
944}
945
946pub fn effect_kind_label(kind: &EffectKind) -> String {
949 match kind {
950 EffectKind::Stdio => "stdio".to_string(),
951 EffectKind::Fs => "fs".to_string(),
952 EffectKind::Net => "net".to_string(),
953 EffectKind::Env => "env".to_string(),
954 EffectKind::Clock => "clock".to_string(),
955 EffectKind::Random => "random".to_string(),
956 EffectKind::Process => "process".to_string(),
957 EffectKind::Secret => "secret".to_string(),
958 EffectKind::Observability => "observability".to_string(),
959 EffectKind::Channel => "channel".to_string(),
960 EffectKind::State => "state".to_string(),
961 EffectKind::Host => "host".to_string(),
962 EffectKind::Llm { provider, model } => match (provider.as_deref(), model.as_deref()) {
963 (Some(provider), Some(model)) => format!("llm:{provider}/{model}"),
964 (Some(provider), None) => format!("llm:{provider}"),
965 (None, Some(model)) => format!("llm:{model}"),
966 (None, None) => "llm".to_string(),
967 },
968 EffectKind::Tool { name } if !name.is_empty() => format!("tool:{name}"),
969 EffectKind::Tool { .. } => "tool".to_string(),
970 EffectKind::Hostcall { name } if !name.is_empty() => format!("hostcall:{name}"),
971 EffectKind::Hostcall { .. } => "hostcall".to_string(),
972 EffectKind::Persona { id } if !id.is_empty() => format!("persona:{id}"),
973 EffectKind::Persona { .. } => "persona".to_string(),
974 EffectKind::Spawn => "spawn".to_string(),
975 }
976}
977
978pub fn effect_record_summary(effect: &EffectRecord) -> String {
980 let scope = match effect.scope {
981 EffectScope::Read => "read",
982 EffectScope::Write => "write",
983 EffectScope::Mutate => "mutate",
984 EffectScope::Observe => "observe",
985 };
986 match effect.resource.as_deref() {
987 Some(resource) if !resource.is_empty() => {
988 format!(
989 "{}:{} ({})",
990 effect_kind_label(&effect.kind),
991 scope,
992 resource
993 )
994 }
995 _ => format!("{}:{}", effect_kind_label(&effect.kind), scope),
996 }
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002
1003 #[test]
1004 fn harness_net_call_yields_net_effect() {
1005 let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test") }"#;
1006 let effects = compute_handoff_effects(source, None);
1007 assert!(
1008 effects
1009 .iter()
1010 .any(|effect| matches!(effect.kind, EffectKind::Net)
1011 && effect.scope == EffectScope::Read
1012 && effect.resource.as_deref() == Some("https://example.test")),
1013 "expected Net read effect, got {effects:?}"
1014 );
1015 }
1016
1017 #[test]
1018 fn harness_process_run_yields_process_hostcall_effect() {
1019 let source = r#"fn main(harness: Harness) {
1020 harness.process.run({program: "printf", args: ["hello"]})
1021 }"#;
1022 let effects = compute_handoff_effects(source, None);
1023 assert!(
1024 effects.iter().any(|effect| {
1025 matches!(&effect.kind, EffectKind::Process)
1026 && effect.scope == EffectScope::Write
1027 && effect.resource.as_deref() == Some("printf")
1028 }),
1029 "expected process hostcall write effect, got {effects:?}"
1030 );
1031 }
1032
1033 #[test]
1034 fn http_get_builtin_yields_net_effect_with_resource() {
1035 let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test/api") }"#;
1036 let effects = compute_handoff_effects(source, None);
1037 let net = effects
1038 .iter()
1039 .find(|effect| matches!(effect.kind, EffectKind::Net))
1040 .expect("net effect");
1041 assert_eq!(net.scope, EffectScope::Read);
1042 assert_eq!(net.resource.as_deref(), Some("https://example.test/api"));
1043 }
1044
1045 #[test]
1046 fn unix_socket_json_request_yields_net_effect_with_resource() {
1047 let source = r#"fn main(harness: Harness) {
1048 harness.net.unix_socket_json_request("/tmp/harn.sock", {})
1049 }"#;
1050 let effects = compute_handoff_effects(source, None);
1051 let net = effects
1052 .iter()
1053 .find(|effect| matches!(effect.kind, EffectKind::Net))
1054 .expect("net effect");
1055 assert_eq!(net.scope, EffectScope::Mutate);
1056 assert_eq!(net.resource.as_deref(), Some("/tmp/harn.sock"));
1057 }
1058
1059 #[test]
1060 fn files_upload_yields_fs_read_and_net_write_effects() {
1061 let source = r#"fn main(harness: Harness) {
1062 harness.llm.upload_file("/tmp/input.pdf", "gemini")
1063 }"#;
1064 let effects = compute_handoff_effects(source, None);
1065 assert!(
1066 effects.iter().any(|effect| {
1067 matches!(effect.kind, EffectKind::Fs)
1068 && effect.scope == EffectScope::Read
1069 && effect.resource.as_deref() == Some("/tmp/input.pdf")
1070 }),
1071 "expected Fs read effect, got {effects:?}"
1072 );
1073 assert!(
1074 effects.iter().any(|effect| {
1075 matches!(effect.kind, EffectKind::Net)
1076 && effect.scope == EffectScope::Write
1077 && effect.resource.as_deref() == Some("gemini")
1078 }),
1079 "expected Net write effect, got {effects:?}"
1080 );
1081 }
1082
1083 #[test]
1084 fn harness_fs_write_yields_fs_write_effect() {
1085 let source = r#"fn main(harness: Harness) { harness.fs.write_text("/tmp/out", "hi") }"#;
1086 let effects = compute_handoff_effects(source, None);
1087 assert!(
1088 effects
1089 .iter()
1090 .any(|effect| matches!(effect.kind, EffectKind::Fs)
1091 && effect.scope == EffectScope::Write
1092 && effect.resource.as_deref() == Some("/tmp/out")),
1093 "expected Fs write effect, got {effects:?}"
1094 );
1095 }
1096
1097 #[test]
1098 fn granular_capability_parameter_preserves_effect_contract() {
1099 let source = r#"
1100fn write_output(fs: HarnessFs) {
1101 fs.write_text("/tmp/out", "hi")
1102}
1103
1104fn main(harness: Harness) {
1105 write_output(harness.fs)
1106}
1107"#;
1108 let effects = compute_handoff_effects(source, None);
1109 assert!(
1110 effects.iter().any(|effect| {
1111 matches!(effect.kind, EffectKind::Fs)
1112 && effect.scope == EffectScope::Write
1113 && effect.resource.as_deref() == Some("/tmp/out")
1114 }),
1115 "expected granular HarnessFs effect, got {effects:?}"
1116 );
1117 }
1118
1119 #[test]
1120 fn capability_alias_preserves_effect_contract() {
1121 let source = r#"
1122fn main(harness: Harness) {
1123 const fs = harness.fs
1124 fs.write_text("/tmp/out", "hi")
1125}
1126"#;
1127 let effects = compute_handoff_effects(source, None);
1128 assert!(
1129 effects.iter().any(|effect| {
1130 matches!(effect.kind, EffectKind::Fs)
1131 && effect.scope == EffectScope::Write
1132 && effect.resource.as_deref() == Some("/tmp/out")
1133 }),
1134 "expected aliased HarnessFs effect, got {effects:?}"
1135 );
1136 }
1137
1138 #[test]
1139 fn capability_method_can_declare_multiple_effects() {
1140 let source = r#"fn main(harness: Harness) {
1141 harness.net.download("https://example.test/data", "/tmp/data")
1142 }"#;
1143 let effects = compute_handoff_effects(source, None);
1144 assert!(
1145 effects.iter().any(|effect| {
1146 matches!(effect.kind, EffectKind::Net)
1147 && effect.scope == EffectScope::Read
1148 && effect.resource.as_deref() == Some("https://example.test/data")
1149 }),
1150 "expected download network effect, got {effects:?}"
1151 );
1152 assert!(
1153 effects.iter().any(|effect| {
1154 matches!(effect.kind, EffectKind::Fs)
1155 && effect.scope == EffectScope::Write
1156 && effect.resource.as_deref() == Some("/tmp/data")
1157 }),
1158 "expected download filesystem effect, got {effects:?}"
1159 );
1160 }
1161
1162 #[test]
1163 fn harness_term_read_password_yields_stdio_read_effect() {
1164 let source = r#"fn main(harness: Harness) { harness.term.read_password("password: ") }"#;
1165 let effects = compute_handoff_effects(source, None);
1166 assert!(
1167 effects
1168 .iter()
1169 .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1170 && effect.scope == EffectScope::Read),
1171 "expected Stdio read effect, got {effects:?}"
1172 );
1173 }
1174
1175 #[test]
1176 fn harness_fs_mkdtemp_yields_fs_write_effect() {
1177 let source = r#"fn main(harness: Harness) { harness.fs.mkdtemp("harn-") }"#;
1178 let effects = compute_handoff_effects(source, None);
1179 assert!(
1180 effects
1181 .iter()
1182 .any(|effect| matches!(effect.kind, EffectKind::Fs)
1183 && effect.scope == EffectScope::Write),
1184 "expected Fs write effect, got {effects:?}"
1185 );
1186 }
1187
1188 #[test]
1189 fn harness_crypto_sha256_is_pure_for_handoff_effects() {
1190 let source = r#"fn main(harness: Harness) { sha256_hex("hello") }"#;
1191 let effects = compute_handoff_effects(source, None);
1192 assert!(effects.is_empty(), "expected no effects, got {effects:?}");
1193 }
1194
1195 #[test]
1196 fn harness_stdio_read_line_yields_stdio_read_effect() {
1197 let source = r"fn main(harness: Harness) { harness.stdio.read_line() }";
1198 let effects = compute_handoff_effects(source, None);
1199 assert!(
1200 effects
1201 .iter()
1202 .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1203 && effect.scope == EffectScope::Read),
1204 "expected Stdio read effect, got {effects:?}"
1205 );
1206 }
1207
1208 #[test]
1209 fn llm_call_emits_llm_effect_with_provider_and_model() {
1210 let source = r#"fn main(harness: Harness) {
1211 harness.llm.call(
1212 "summarize",
1213 nil,
1214 { provider: "anthropic", model: "claude-3-5-sonnet" },
1215 )
1216 }"#;
1217 let effects = compute_handoff_effects(source, None);
1218 let llm = effects
1219 .iter()
1220 .find(|effect| matches!(effect.kind, EffectKind::Llm { .. }))
1221 .expect("llm effect");
1222 let EffectKind::Llm { provider, model } = &llm.kind else {
1223 panic!("expected llm kind, got {:?}", llm.kind);
1224 };
1225 assert_eq!(provider.as_deref(), Some("anthropic"));
1226 assert_eq!(model.as_deref(), Some("claude-3-5-sonnet"));
1227 }
1228
1229 #[test]
1230 fn runtime_llm_contract_combines_provider_and_model_resources() {
1231 let entry = crate::stdlib::builtin_manifest_entry("__cap_llm_call")
1232 .expect("LLM capability manifest entry");
1233 let options = VmValue::dict(crate::value::DictMap::from_iter([
1234 ("provider", VmValue::String("anthropic".into())),
1235 ("model", VmValue::String("claude-sonnet-4".into())),
1236 ]));
1237 let effects = runtime_effects_from_contract(
1238 entry.contract.effects,
1239 &[VmValue::Nil, VmValue::Nil, options],
1240 );
1241 assert_eq!(effects.len(), 1);
1242 assert!(matches!(
1243 &effects[0].kind,
1244 EffectKind::Llm { provider: Some(provider), model: Some(model) }
1245 if provider == "anthropic" && model == "claude-sonnet-4"
1246 ));
1247 }
1248
1249 #[test]
1250 fn harness_llm_catalog_yields_read_effect() {
1251 let source = r"fn main(harness: Harness) {
1252 harness.llm.catalog()
1253 harness.llm.providers()
1254 }";
1255 let effects = compute_handoff_effects(source, None);
1256 assert!(
1257 effects
1258 .iter()
1259 .any(|effect| matches!(effect.kind, EffectKind::Llm { .. })
1260 && effect.scope == EffectScope::Read),
1261 "expected LLM read effect, got {effects:?}"
1262 );
1263 }
1264
1265 #[test]
1266 fn ceiling_drops_disallowed_capabilities() {
1267 let source = r#"fn main(harness: Harness) {
1268 harness.net.get("https://example.test")
1269 harness.fs.read_text("/tmp/in")
1270 }"#;
1271 let mut ceiling = CapabilityPolicy::default();
1272 ceiling
1273 .capabilities
1274 .insert("workspace".to_string(), vec!["read_text".to_string()]);
1275 let effects = compute_handoff_effects(source, Some(&ceiling));
1276 assert!(
1277 effects
1278 .iter()
1279 .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1280 "ceiling without `network` should drop Net effect, got {effects:?}"
1281 );
1282 assert!(
1283 effects
1284 .iter()
1285 .any(|effect| matches!(effect.kind, EffectKind::Fs)),
1286 "ceiling with workspace.read_text should keep Fs read, got {effects:?}"
1287 );
1288 }
1289
1290 #[test]
1291 fn ceiling_side_effect_level_clamps_writes() {
1292 let source = r#"fn main(harness: Harness) {
1293 harness.net.get("https://example.test")
1294 harness.stdio.println("hi")
1295 }"#;
1296 let ceiling = CapabilityPolicy {
1297 side_effect_level: Some("read_only".to_string()),
1298 ..Default::default()
1299 };
1300 let effects = compute_handoff_effects(source, Some(&ceiling));
1301 assert!(
1302 effects
1303 .iter()
1304 .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1305 "read_only ceiling must drop Net write, got {effects:?}"
1306 );
1307 assert!(
1308 effects
1309 .iter()
1310 .any(|effect| matches!(effect.kind, EffectKind::Stdio)),
1311 "stdio observe should pass read_only ceiling, got {effects:?}"
1312 );
1313 }
1314
1315 #[test]
1316 fn effect_record_round_trips_through_serde() {
1317 let effects = vec![
1318 EffectRecord::new(EffectKind::Net, EffectScope::Write)
1319 .with_resource("https://api.example/v1"),
1320 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1321 EffectRecord::new(
1322 EffectKind::Llm {
1323 provider: Some("anthropic".to_string()),
1324 model: Some("claude-3-7-sonnet".to_string()),
1325 },
1326 EffectScope::Write,
1327 ),
1328 EffectRecord::new(
1329 EffectKind::Tool {
1330 name: "search".to_string(),
1331 },
1332 EffectScope::Read,
1333 ),
1334 ];
1335 let encoded = serde_json::to_string(&effects).expect("encode");
1336 let decoded: Vec<EffectRecord> = serde_json::from_str(&encoded).expect("decode");
1337 assert_eq!(decoded, effects);
1338 }
1339
1340 #[test]
1341 fn empty_source_returns_no_effects() {
1342 let effects = compute_handoff_effects("fn main() {}", None);
1343 assert!(effects.is_empty(), "got {effects:?}");
1344 }
1345
1346 #[test]
1347 fn effects_from_metadata_round_trips_typed_payload() {
1348 let effects = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1349 .with_resource("https://api.example")];
1350 let mut metadata: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1351 metadata.insert(
1352 "effects".to_string(),
1353 serde_json::to_value(&effects).expect("encode"),
1354 );
1355 assert_eq!(effects_from_metadata(&metadata), effects);
1356 }
1357
1358 #[test]
1359 fn subset_violations_returns_empty_when_child_covered() {
1360 let parent = vec![
1361 EffectRecord::new(EffectKind::Net, EffectScope::Write),
1362 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1363 ];
1364 let child = vec![
1365 EffectRecord::new(EffectKind::Net, EffectScope::Write)
1366 .with_resource("https://example.test"),
1367 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1368 ];
1369 assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1370 }
1371
1372 #[test]
1373 fn subset_violations_flags_unmatched_kinds() {
1374 let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1375 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1376 .with_resource("https://example.test")];
1377 let violations = effect_subset_violations(Some(&parent), &child);
1378 assert_eq!(violations.len(), 1);
1379 assert!(matches!(violations[0].kind, EffectKind::Net));
1380 }
1381
1382 #[test]
1383 fn subset_violations_flags_scope_escalations() {
1384 let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1385 let child = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)];
1386 let violations = effect_subset_violations(Some(&parent), &child);
1387 assert_eq!(violations.len(), 1);
1388 assert_eq!(violations[0].scope, EffectScope::Mutate);
1389 }
1390
1391 #[test]
1392 fn subset_violations_treats_missing_parent_resource_as_wildcard() {
1393 let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1394 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1395 .with_resource("https://api.example/v1")];
1396 assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1397 }
1398
1399 #[test]
1400 fn subset_violations_requires_resource_match_when_parent_declares_one() {
1401 let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1402 .with_resource("https://allowed.test")];
1403 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1404 .with_resource("https://disallowed.test")];
1405 let violations = effect_subset_violations(Some(&parent), &child);
1406 assert_eq!(violations.len(), 1);
1407 }
1408
1409 #[test]
1410 fn subset_violations_skip_when_parent_is_none() {
1411 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1412 assert!(effect_subset_violations(None, &child).is_empty());
1413 }
1414
1415 #[test]
1416 fn subset_violations_empty_parent_flags_every_child_effect() {
1417 let parent: Vec<EffectRecord> = Vec::new();
1418 let child = vec![
1419 EffectRecord::new(EffectKind::Net, EffectScope::Write),
1420 EffectRecord::new(EffectKind::Fs, EffectScope::Read),
1421 ];
1422 let violations = effect_subset_violations(Some(&parent), &child);
1423 assert_eq!(violations.len(), 2);
1424 }
1425
1426 #[test]
1427 fn subset_violations_empty_child_is_always_allowed() {
1428 let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1429 assert!(effect_subset_violations(Some(&parent), &[]).is_empty());
1430 }
1431
1432 #[test]
1433 fn effect_kind_label_shape() {
1434 assert_eq!(effect_kind_label(&EffectKind::Net), "net");
1435 assert_eq!(
1436 effect_kind_label(&EffectKind::Llm {
1437 provider: Some("anthropic".to_string()),
1438 model: Some("claude-3-7-sonnet".to_string()),
1439 }),
1440 "llm:anthropic/claude-3-7-sonnet"
1441 );
1442 assert_eq!(
1443 effect_kind_label(&EffectKind::Tool {
1444 name: "search".to_string()
1445 }),
1446 "tool:search"
1447 );
1448 }
1449
1450 #[test]
1451 fn effect_record_summary_includes_resource() {
1452 let effect = EffectRecord::new(EffectKind::Net, EffectScope::Write)
1453 .with_resource("https://example.test/api");
1454 assert_eq!(
1455 effect_record_summary(&effect),
1456 "net:write (https://example.test/api)"
1457 );
1458 }
1459
1460 #[test]
1461 fn deduplicates_repeated_effects() {
1462 let source = r#"fn main(harness: Harness) {
1463 harness.net.get("https://example.test")
1464 harness.net.get("https://example.test")
1465 harness.net.get("https://example.test")
1466 }"#;
1467 let effects = compute_handoff_effects(source, None);
1468 let net_count = effects
1469 .iter()
1470 .filter(|effect| matches!(effect.kind, EffectKind::Net))
1471 .count();
1472 assert_eq!(net_count, 1, "expected dedup, got {effects:?}");
1473 }
1474}