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