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