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;
23
24#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
27#[serde(tag = "kind", rename_all = "snake_case")]
28pub enum EffectKind {
29 Stdio,
31 Fs,
33 Net,
35 Llm {
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 provider: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 model: Option<String>,
42 },
43 Tool { name: String },
45 Hostcall { name: String },
47 Persona { id: String },
49 Spawn,
51}
52
53#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
56#[serde(rename_all = "snake_case")]
57pub enum EffectScope {
58 Read,
60 Write,
62 Mutate,
64 Observe,
66}
67
68#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
75pub struct EffectRecord {
76 pub kind: EffectKind,
77 pub scope: EffectScope,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub resource: Option<String>,
80}
81
82impl EffectRecord {
83 pub fn new(kind: EffectKind, scope: EffectScope) -> Self {
84 Self {
85 kind,
86 scope,
87 resource: None,
88 }
89 }
90
91 pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
92 let resource = resource.into();
93 self.resource = if resource.is_empty() {
94 None
95 } else {
96 Some(resource)
97 };
98 self
99 }
100}
101
102pub fn compute_handoff_effects(
116 source: &str,
117 ceiling: Option<&CapabilityPolicy>,
118) -> Vec<EffectRecord> {
119 let Ok(program) = harn_parser::parse_source(source) else {
120 return Vec::new();
121 };
122 let mut collected: BTreeSet<EffectRecord> = BTreeSet::new();
123
124 let report = harn_ir::analyze_program(&program);
127 for handler in &report.handlers {
128 for node in &handler.nodes {
129 let NodeSemantics::Call(call) = &node.semantics else {
130 continue;
131 };
132 for effect in effects_from_call(call) {
133 collected.insert(effect);
134 }
135 }
136 }
137
138 for node in &program {
142 walk_for_harness_effects(node, &mut collected);
143 }
144
145 let mut effects: Vec<EffectRecord> = collected.into_iter().collect();
146 if let Some(ceiling) = ceiling {
147 effects.retain(|effect| effect_allowed_by_ceiling(effect, ceiling));
148 }
149 effects
150}
151
152fn effects_from_call(call: &harn_ir::CallSemantics) -> Vec<EffectRecord> {
153 if let Some(effect) = builtin_effect(&call.name) {
157 return vec![annotate_with_resource(effect, call)];
158 }
159 if call.name == "host_call" {
160 if let Some(operation) = call.literal_args.first().and_then(literal_as_str) {
161 return vec![EffectRecord::new(
162 EffectKind::Hostcall {
163 name: operation.to_string(),
164 },
165 hostcall_scope(operation),
166 )];
167 }
168 }
169 if let CallClassification::Capabilities(capability_effects) = &call.classification {
176 return capability_effects
177 .iter()
178 .filter_map(capability_effect_to_record)
179 .collect();
180 }
181 Vec::new()
182}
183
184fn builtin_effect(name: &str) -> Option<EffectRecord> {
185 match name {
186 "print" | "println" | "eprint" | "eprintln" | "write_stdout" | "write_stderr"
188 | "__io_print" | "__io_println" | "__io_eprint" | "__io_eprintln" | "__io_write_stdout"
189 | "__io_write_stderr" => Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Observe)),
190 "read_line" | "read_stdin" | "prompt_user" | "__io_read_line" => {
191 Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Read))
192 }
193
194 "read_file"
196 | "read_file_bytes"
197 | "read_file_result"
198 | "render"
199 | "render_prompt"
200 | "render_with_provenance"
201 | "read_lines"
202 | "list_dir"
203 | "walk_dir"
204 | "glob"
205 | "file_exists"
206 | "stat" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Read)),
207
208 "write_file" | "write_file_bytes" | "append_file" | "mkdir" | "mkdtemp" | "copy_file"
210 | "move_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Write)),
211 "delete_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
212 "apply_edit" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
213
214 "http_get"
218 | "http_post"
219 | "http_put"
220 | "http_patch"
221 | "http_delete"
222 | "http_request"
223 | "http_download"
224 | "http_session"
225 | "http_session_request"
226 | "http_session_close"
227 | "http_stream_open"
228 | "http_stream_read"
229 | "http_stream_close"
230 | "sse_connect"
231 | "sse_receive"
232 | "sse_close"
233 | "sse_server_response"
234 | "sse_server_send"
235 | "sse_server_heartbeat"
236 | "sse_server_flush"
237 | "sse_server_close"
238 | "sse_server_cancel"
239 | "websocket_connect"
240 | "websocket_accept"
241 | "websocket_send"
242 | "websocket_receive"
243 | "websocket_close"
244 | "websocket_route"
245 | "websocket_server"
246 | "websocket_server_close" => Some(EffectRecord::new(EffectKind::Net, EffectScope::Write)),
247
248 "llm_call"
250 | "llm_call_safe"
251 | "llm_stream_call"
252 | "llm_call_structured"
253 | "llm_call_structured_safe"
254 | "llm_call_structured_result"
255 | "llm_completion"
256 | "agent_llm_turn"
257 | "agent_turn"
258 | "agent_loop" => Some(EffectRecord::new(
259 EffectKind::Llm {
260 provider: None,
261 model: None,
262 },
263 EffectScope::Write,
264 )),
265 "llm_catalog" | "llm_provider_status" => Some(EffectRecord::new(
266 EffectKind::Llm {
267 provider: None,
268 model: None,
269 },
270 EffectScope::Read,
271 )),
272
273 "spawn_agent"
275 | "send_input"
276 | "resume_agent"
277 | "wait_agent"
278 | "close_agent"
279 | "worker_trigger"
280 | "__host_sub_agent_run"
281 | "__host_worker_spawn"
282 | "__host_worker_send_input"
283 | "__host_worker_resume"
284 | "__host_worker_trigger"
285 | "__host_worker_wait"
286 | "__host_worker_close" => Some(EffectRecord::new(EffectKind::Spawn, EffectScope::Write)),
287
288 "tool_call" | "host_tool_call" => Some(EffectRecord::new(
290 EffectKind::Tool {
291 name: String::new(),
292 },
293 EffectScope::Write,
294 )),
295
296 _ => None,
297 }
298}
299
300fn annotate_with_resource(mut effect: EffectRecord, call: &harn_ir::CallSemantics) -> EffectRecord {
301 match &mut effect.kind {
305 EffectKind::Llm { provider, model } => {
306 for arg in &call.literal_args {
307 if let LiteralValue::Dict(entries) = arg {
308 if let Some(value) = entries.get("provider").and_then(literal_as_str) {
309 *provider = Some(value.to_string());
310 }
311 if let Some(value) = entries.get("model").and_then(literal_as_str) {
312 *model = Some(value.to_string());
313 }
314 }
315 }
316 }
317 EffectKind::Tool { name } => {
318 if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
319 *name = value.to_string();
320 }
321 }
322 _ => {
323 if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
324 effect.resource = Some(value.to_string());
325 }
326 }
327 }
328 effect
329}
330
331fn capability_effect_to_record(effect: &harn_ir::CapabilityEffect) -> Option<EffectRecord> {
332 let (kind, scope) = match effect.capability {
333 Capability::WorkspaceMutation => (EffectKind::Fs, EffectScope::Mutate),
334 Capability::CommandExecution => (
335 EffectKind::Hostcall {
336 name: format!("process.{}", effect.operation),
337 },
338 EffectScope::Write,
339 ),
340 Capability::NetworkAccess => (EffectKind::Net, EffectScope::Write),
341 Capability::ConnectorAccess => (
342 EffectKind::Hostcall {
343 name: if effect.operation.is_empty() {
344 "connector.call".to_string()
345 } else {
346 format!("connector.{}", effect.operation)
347 },
348 },
349 EffectScope::Write,
350 ),
351 Capability::ModelCall => (
352 EffectKind::Llm {
353 provider: None,
354 model: None,
355 },
356 EffectScope::Write,
357 ),
358 Capability::WorkerDispatch => (EffectKind::Spawn, EffectScope::Write),
359 Capability::HumanApproval => return None,
360 Capability::AutonomyPolicy => return None,
361 };
362 let resource = effect.path.clone();
363 Some(EffectRecord {
364 kind,
365 scope,
366 resource,
367 })
368}
369
370fn hostcall_scope(operation: &str) -> EffectScope {
371 match operation {
372 op if op.starts_with("workspace.read") || op.starts_with("workspace.list") => {
373 EffectScope::Read
374 }
375 op if op.starts_with("workspace.write") || op == "workspace.apply_edit" => {
376 EffectScope::Mutate
377 }
378 op if op.starts_with("process.") => EffectScope::Write,
379 _ => EffectScope::Write,
380 }
381}
382
383fn literal_as_str(value: &LiteralValue) -> Option<&str> {
384 match value {
385 LiteralValue::String(value) | LiteralValue::Identifier(value) => Some(value.as_str()),
386 _ => None,
387 }
388}
389
390fn walk_for_harness_effects(node: &SNode, out: &mut BTreeSet<EffectRecord>) {
391 if let Some(effect) = harness_method_effect(node) {
392 out.insert(effect);
393 }
394 for child in child_nodes(node) {
395 walk_for_harness_effects(child, out);
396 }
397}
398
399fn harness_method_effect(node: &SNode) -> Option<EffectRecord> {
400 let (object, method) = match &node.node {
401 Node::MethodCall { object, method, .. }
402 | Node::OptionalMethodCall { object, method, .. } => (object, method),
403 _ => return None,
404 };
405 let (sub_handle, root) = harness_sub_handle(object)?;
406 if !is_harness_root(root) {
407 return None;
408 }
409 let (kind, scope) = match (sub_handle.as_str(), method.as_str()) {
410 ("stdio", "print" | "println" | "eprint" | "eprintln") => {
411 (EffectKind::Stdio, EffectScope::Observe)
412 }
413 ("stdio", "read_line" | "prompt") => (EffectKind::Stdio, EffectScope::Read),
414 ("term", "width" | "height" | "read_password") => (EffectKind::Stdio, EffectScope::Read),
415 ("clock", _) => return None,
416 ("env", "set" | "unset") => (
417 EffectKind::Hostcall {
418 name: "env.set".to_string(),
419 },
420 EffectScope::Mutate,
421 ),
422 ("env", _) => (
423 EffectKind::Hostcall {
424 name: "env.get".to_string(),
425 },
426 EffectScope::Read,
427 ),
428 ("random", _) => return None,
429 ("fs", "read_file" | "read_text" | "read" | "exists" | "list_dir" | "stat") => {
430 (EffectKind::Fs, EffectScope::Read)
431 }
432 ("fs", "write_file" | "write_text" | "append_file" | "mkdir" | "mkdtemp" | "copy_file") => {
433 (EffectKind::Fs, EffectScope::Write)
434 }
435 ("fs", "delete_file" | "delete" | "remove") => (EffectKind::Fs, EffectScope::Mutate),
436 ("fs", _) => (EffectKind::Fs, EffectScope::Read),
437 ("net", _) => (EffectKind::Net, EffectScope::Write),
438 ("process", "spawn_captured") => (
439 EffectKind::Hostcall {
440 name: "process.spawn_captured".to_string(),
441 },
442 EffectScope::Write,
443 ),
444 ("crypto", "sha256") => return None,
445 ("system", _) => return None,
452 ("llm", "catalog" | "providers") => (
453 EffectKind::Llm {
454 provider: None,
455 model: None,
456 },
457 EffectScope::Read,
458 ),
459 ("llm", _) => return None,
460 _ => return None,
461 };
462 Some(EffectRecord::new(kind, scope))
463}
464
465fn harness_sub_handle(node: &SNode) -> Option<(String, &SNode)> {
466 match &node.node {
467 Node::PropertyAccess { object, property }
468 | Node::OptionalPropertyAccess { object, property } => {
469 Some((property.clone(), object.as_ref()))
470 }
471 _ => None,
472 }
473}
474
475fn is_harness_root(node: &SNode) -> bool {
476 matches!(&node.node, Node::Identifier(name) if name == "harness")
477}
478
479fn child_nodes(node: &SNode) -> Vec<&SNode> {
480 let mut children: Vec<&SNode> = Vec::new();
481 match &node.node {
482 Node::AttributedDecl { inner, .. } => children.push(inner.as_ref()),
483 Node::Pipeline { body, .. }
484 | Node::FnDecl { body, .. }
485 | Node::ToolDecl { body, .. }
486 | Node::SpawnExpr { body }
487 | Node::Retry { body, .. }
488 | Node::TryExpr { body }
489 | Node::DeferStmt { body }
490 | Node::MutexBlock { body }
491 | Node::Block(body)
492 | Node::OverrideDecl { body, .. } => children.extend(body.iter()),
493 Node::ImplBlock { methods, .. } => children.extend(methods.iter()),
494 Node::IfElse {
495 condition,
496 then_body,
497 else_body,
498 } => {
499 children.push(condition.as_ref());
500 children.extend(then_body.iter());
501 if let Some(else_body) = else_body.as_ref() {
502 children.extend(else_body.iter());
503 }
504 }
505 Node::ForIn { iterable, body, .. } => {
506 children.push(iterable.as_ref());
507 children.extend(body.iter());
508 }
509 Node::WhileLoop { condition, body } => {
510 children.push(condition.as_ref());
511 children.extend(body.iter());
512 }
513 Node::MatchExpr { value, arms } => {
514 children.push(value.as_ref());
515 for arm in arms {
516 if let Some(guard) = arm.guard.as_ref() {
517 children.push(guard.as_ref());
518 }
519 children.extend(arm.body.iter());
520 }
521 }
522 Node::CostRoute { options, body } => {
523 for (_key, value) in options {
524 children.push(value);
525 }
526 children.extend(body.iter());
527 }
528 Node::ReturnStmt { value } => {
529 if let Some(value) = value.as_ref() {
530 children.push(value.as_ref());
531 }
532 }
533 Node::ThrowStmt { value } => children.push(value.as_ref()),
534 Node::TryCatch {
535 body,
536 catch_body,
537 finally_body,
538 ..
539 } => {
540 children.extend(body.iter());
541 children.extend(catch_body.iter());
542 if let Some(finally_body) = finally_body.as_ref() {
543 children.extend(finally_body.iter());
544 }
545 }
546 Node::SkillDecl { fields, .. } => {
547 for (_name, value) in fields {
548 children.push(value);
549 }
550 }
551 Node::EvalPackDecl {
552 fields,
553 body,
554 summarize,
555 ..
556 } => {
557 for (_name, value) in fields {
558 children.push(value);
559 }
560 children.extend(body.iter());
561 if let Some(summarize) = summarize.as_ref() {
562 children.extend(summarize.iter());
563 }
564 }
565 Node::LetBinding { value, .. } | Node::VarBinding { value, .. } => {
566 children.push(value.as_ref());
567 }
568 Node::ConstBinding { value, .. } => {
569 children.push(value.as_ref());
570 }
571 Node::DeadlineBlock { duration, body } => {
572 children.push(duration.as_ref());
573 children.extend(body.iter());
574 }
575 Node::YieldExpr { value } => {
576 if let Some(value) = value.as_ref() {
577 children.push(value.as_ref());
578 }
579 }
580 Node::EmitExpr { value } => children.push(value.as_ref()),
581 Node::GuardStmt {
582 condition,
583 else_body,
584 } => {
585 children.push(condition.as_ref());
586 children.extend(else_body.iter());
587 }
588 Node::RequireStmt { condition, message } => {
589 children.push(condition.as_ref());
590 if let Some(message) = message.as_ref() {
591 children.push(message.as_ref());
592 }
593 }
594 Node::HitlExpr { args, .. } => {
595 for arg in args {
596 children.push(&arg.value);
597 }
598 }
599 Node::Parallel {
600 expr,
601 body,
602 options,
603 ..
604 } => {
605 children.push(expr.as_ref());
606 children.extend(body.iter());
607 for (_key, value) in options {
608 children.push(value);
609 }
610 }
611 Node::SelectExpr {
612 cases,
613 timeout,
614 default_body,
615 } => {
616 for case in cases {
617 children.push(case.channel.as_ref());
618 children.extend(case.body.iter());
619 }
620 if let Some((duration, body)) = timeout.as_ref() {
621 children.push(duration.as_ref());
622 children.extend(body.iter());
623 }
624 if let Some(body) = default_body.as_ref() {
625 children.extend(body.iter());
626 }
627 }
628 Node::FunctionCall { args, .. } => children.extend(args.iter()),
629 Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
630 children.push(object.as_ref());
631 children.extend(args.iter());
632 }
633 Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
634 children.push(object.as_ref());
635 }
636 Node::SubscriptAccess { object, index }
637 | Node::OptionalSubscriptAccess { object, index } => {
638 children.push(object.as_ref());
639 children.push(index.as_ref());
640 }
641 Node::SliceAccess { object, start, end } => {
642 children.push(object.as_ref());
643 if let Some(start) = start.as_ref() {
644 children.push(start.as_ref());
645 }
646 if let Some(end) = end.as_ref() {
647 children.push(end.as_ref());
648 }
649 }
650 Node::BinaryOp { left, right, .. } => {
651 children.push(left.as_ref());
652 children.push(right.as_ref());
653 }
654 Node::UnaryOp { operand, .. } => children.push(operand.as_ref()),
655 Node::Ternary {
656 condition,
657 true_expr,
658 false_expr,
659 } => {
660 children.push(condition.as_ref());
661 children.push(true_expr.as_ref());
662 children.push(false_expr.as_ref());
663 }
664 Node::Assignment { target, value, .. } => {
665 children.push(target.as_ref());
666 children.push(value.as_ref());
667 }
668 Node::EnumConstruct { args, .. } => children.extend(args.iter()),
669 Node::StructConstruct { fields, .. } => {
670 for entry in fields {
671 children.push(&entry.key);
672 children.push(&entry.value);
673 }
674 }
675 Node::ListLiteral(items) => children.extend(items.iter()),
676 Node::DictLiteral(entries) => {
677 for entry in entries {
678 children.push(&entry.key);
679 children.push(&entry.value);
680 }
681 }
682 Node::Spread(inner) => children.push(inner.as_ref()),
683 Node::TryOperator { operand } | Node::TryStar { operand } => {
684 children.push(operand.as_ref());
685 }
686 Node::OrPattern(items) => children.extend(items.iter()),
687 Node::Closure { body, .. } => children.extend(body.iter()),
688 Node::RangeExpr { start, end, .. } => {
689 children.push(start.as_ref());
690 children.push(end.as_ref());
691 }
692 _ => {}
693 }
694 children
695}
696
697fn effect_allowed_by_ceiling(effect: &EffectRecord, ceiling: &CapabilityPolicy) -> bool {
698 if !ceiling.capabilities.is_empty() {
699 let (capability, op) = effect_capability_op(effect);
700 let allowed = ceiling
701 .capabilities
702 .get(capability)
703 .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op));
704 if !allowed {
705 return false;
706 }
707 }
708 if let Some(ceiling_level) = ceiling.side_effect_level.as_deref() {
709 let requested = side_effect_level_for(effect);
710 if requested_exceeds_ceiling(requested, ceiling_level) {
711 return false;
712 }
713 }
714 true
715}
716
717fn effect_capability_op(effect: &EffectRecord) -> (&'static str, &'static str) {
718 match (&effect.kind, effect.scope) {
719 (EffectKind::Stdio, EffectScope::Read) => ("stdio", "read"),
720 (EffectKind::Stdio, _) => ("stdio", "write"),
721 (EffectKind::Fs, EffectScope::Read) => ("workspace", "read_text"),
722 (EffectKind::Fs, EffectScope::Write) => ("workspace", "write_text"),
723 (EffectKind::Fs, EffectScope::Mutate) => ("workspace", "apply_edit"),
724 (EffectKind::Fs, EffectScope::Observe) => ("workspace", "exists"),
725 (EffectKind::Net, _) => ("network", "http"),
726 (EffectKind::Llm { .. }, EffectScope::Read) => ("llm", "catalog"),
727 (EffectKind::Llm { .. }, _) => ("llm", "call"),
728 (EffectKind::Tool { .. }, _) => ("host", "tool_call"),
729 (EffectKind::Hostcall { .. }, _) => ("connector", "call"),
730 (EffectKind::Persona { .. }, _) => ("worker", "dispatch"),
731 (EffectKind::Spawn, _) => ("worker", "dispatch"),
732 }
733}
734
735fn side_effect_level_for(effect: &EffectRecord) -> &'static str {
736 match (&effect.kind, effect.scope) {
737 (EffectKind::Stdio, _) => "read_only",
738 (EffectKind::Fs, EffectScope::Read | EffectScope::Observe) => "read_only",
739 (EffectKind::Fs, _) => "workspace_write",
740 (EffectKind::Net, _) => "network",
741 (EffectKind::Llm { .. }, EffectScope::Read) => "read_only",
742 (EffectKind::Llm { .. }, _) => "network",
743 (EffectKind::Tool { .. }, _) => "workspace_write",
744 (EffectKind::Hostcall { name }, _) if name.starts_with("process.") => "process_exec",
745 (EffectKind::Hostcall { .. }, _) => "read_only",
746 (EffectKind::Persona { .. }, _) => "workspace_write",
747 (EffectKind::Spawn, _) => "workspace_write",
748 }
749}
750
751fn requested_exceeds_ceiling(requested: &str, ceiling: &str) -> bool {
752 fn rank(value: &str) -> usize {
753 match value {
754 "none" => 0,
755 "read_only" => 1,
756 "workspace_write" => 2,
757 "process_exec" => 3,
758 "network" => 4,
759 _ => 5,
760 }
761 }
762 rank(requested) > rank(ceiling)
763}
764
765pub fn effects_from_metadata(metadata: &BTreeMap<String, serde_json::Value>) -> Vec<EffectRecord> {
769 metadata
770 .get("effects")
771 .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
772 .unwrap_or_default()
773}
774
775fn parent_covers_child(parent: &EffectRecord, child: &EffectRecord) -> bool {
785 if !effect_kind_family_matches(&parent.kind, &child.kind) {
786 return false;
787 }
788 if !effect_scope_covers(parent.scope, child.scope) {
789 return false;
790 }
791 match (parent.resource.as_deref(), child.resource.as_deref()) {
792 (Some(""), _) => true,
793 (Some(parent_resource), Some(child_resource)) => parent_resource == child_resource,
794 (Some(_), None) => false,
795 (None, _) => true,
796 }
797}
798
799fn effect_kind_family_matches(parent: &EffectKind, child: &EffectKind) -> bool {
800 match (parent, child) {
801 (EffectKind::Stdio, EffectKind::Stdio)
802 | (EffectKind::Fs, EffectKind::Fs)
803 | (EffectKind::Net, EffectKind::Net)
804 | (EffectKind::Spawn, EffectKind::Spawn) => true,
805 (EffectKind::Llm { .. }, EffectKind::Llm { .. }) => true,
806 (
807 EffectKind::Tool {
808 name: parent_name, ..
809 },
810 EffectKind::Tool {
811 name: child_name, ..
812 },
813 ) => parent_name.is_empty() || parent_name == child_name,
814 (
815 EffectKind::Hostcall {
816 name: parent_name, ..
817 },
818 EffectKind::Hostcall {
819 name: child_name, ..
820 },
821 ) => parent_name.is_empty() || parent_name == child_name,
822 (EffectKind::Persona { id: parent_id }, EffectKind::Persona { id: child_id }) => {
823 parent_id.is_empty() || parent_id == child_id
824 }
825 _ => false,
826 }
827}
828
829fn effect_scope_covers(parent: EffectScope, child: EffectScope) -> bool {
830 fn rank(scope: EffectScope) -> u8 {
831 match scope {
832 EffectScope::Read => 1,
833 EffectScope::Observe => 1,
834 EffectScope::Write => 2,
835 EffectScope::Mutate => 3,
836 }
837 }
838 rank(parent) >= rank(child)
839}
840
841pub fn effect_subset_violations(
848 parent: Option<&[EffectRecord]>,
849 child: &[EffectRecord],
850) -> Vec<EffectRecord> {
851 let Some(parent) = parent else {
852 return Vec::new();
853 };
854 child
855 .iter()
856 .filter(|effect| {
857 !parent
858 .iter()
859 .any(|allowed| parent_covers_child(allowed, effect))
860 })
861 .cloned()
862 .collect()
863}
864
865pub fn effect_kind_label(kind: &EffectKind) -> String {
868 match kind {
869 EffectKind::Stdio => "stdio".to_string(),
870 EffectKind::Fs => "fs".to_string(),
871 EffectKind::Net => "net".to_string(),
872 EffectKind::Llm { provider, model } => match (provider.as_deref(), model.as_deref()) {
873 (Some(provider), Some(model)) => format!("llm:{provider}/{model}"),
874 (Some(provider), None) => format!("llm:{provider}"),
875 (None, Some(model)) => format!("llm:{model}"),
876 (None, None) => "llm".to_string(),
877 },
878 EffectKind::Tool { name } if !name.is_empty() => format!("tool:{name}"),
879 EffectKind::Tool { .. } => "tool".to_string(),
880 EffectKind::Hostcall { name } if !name.is_empty() => format!("hostcall:{name}"),
881 EffectKind::Hostcall { .. } => "hostcall".to_string(),
882 EffectKind::Persona { id } if !id.is_empty() => format!("persona:{id}"),
883 EffectKind::Persona { .. } => "persona".to_string(),
884 EffectKind::Spawn => "spawn".to_string(),
885 }
886}
887
888pub fn effect_record_summary(effect: &EffectRecord) -> String {
890 let scope = match effect.scope {
891 EffectScope::Read => "read",
892 EffectScope::Write => "write",
893 EffectScope::Mutate => "mutate",
894 EffectScope::Observe => "observe",
895 };
896 match effect.resource.as_deref() {
897 Some(resource) if !resource.is_empty() => {
898 format!(
899 "{}:{} ({})",
900 effect_kind_label(&effect.kind),
901 scope,
902 resource
903 )
904 }
905 _ => format!("{}:{}", effect_kind_label(&effect.kind), scope),
906 }
907}
908
909#[cfg(test)]
910mod tests {
911 use super::*;
912
913 #[test]
914 fn harness_net_call_yields_net_effect() {
915 let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test") }"#;
916 let effects = compute_handoff_effects(source, None);
917 assert!(
918 effects
919 .iter()
920 .any(|effect| matches!(effect.kind, EffectKind::Net)
921 && effect.scope == EffectScope::Write),
922 "expected Net write effect, got {effects:?}"
923 );
924 }
925
926 #[test]
927 fn harness_process_spawn_captured_yields_process_hostcall_effect() {
928 let source =
929 r#"fn main(harness: Harness) { harness.process.spawn_captured({cmd: "printf"}) }"#;
930 let effects = compute_handoff_effects(source, None);
931 assert!(
932 effects.iter().any(|effect| {
933 matches!(
934 &effect.kind,
935 EffectKind::Hostcall { name } if name == "process.spawn_captured"
936 ) && effect.scope == EffectScope::Write
937 }),
938 "expected process hostcall write effect, got {effects:?}"
939 );
940 }
941
942 #[test]
943 fn http_get_builtin_yields_net_effect_with_resource() {
944 let source = r#"fn main() { http_get("https://example.test/api") }"#;
945 let effects = compute_handoff_effects(source, None);
946 let net = effects
947 .iter()
948 .find(|effect| matches!(effect.kind, EffectKind::Net))
949 .expect("net effect");
950 assert_eq!(net.scope, EffectScope::Write);
951 assert_eq!(net.resource.as_deref(), Some("https://example.test/api"));
952 }
953
954 #[test]
955 fn harness_fs_write_yields_fs_write_effect() {
956 let source = r#"fn main(harness: Harness) { harness.fs.write_file("/tmp/out", "hi") }"#;
957 let effects = compute_handoff_effects(source, None);
958 assert!(
959 effects
960 .iter()
961 .any(|effect| matches!(effect.kind, EffectKind::Fs)
962 && effect.scope == EffectScope::Write),
963 "expected Fs write effect, got {effects:?}"
964 );
965 }
966
967 #[test]
968 fn harness_term_read_password_yields_stdio_read_effect() {
969 let source = r#"fn main(harness: Harness) { harness.term.read_password("password: ") }"#;
970 let effects = compute_handoff_effects(source, None);
971 assert!(
972 effects
973 .iter()
974 .any(|effect| matches!(effect.kind, EffectKind::Stdio)
975 && effect.scope == EffectScope::Read),
976 "expected Stdio read effect, got {effects:?}"
977 );
978 }
979
980 #[test]
981 fn harness_fs_mkdtemp_yields_fs_write_effect() {
982 let source = r#"fn main(harness: Harness) { harness.fs.mkdtemp("harn-") }"#;
983 let effects = compute_handoff_effects(source, None);
984 assert!(
985 effects
986 .iter()
987 .any(|effect| matches!(effect.kind, EffectKind::Fs)
988 && effect.scope == EffectScope::Write),
989 "expected Fs write effect, got {effects:?}"
990 );
991 }
992
993 #[test]
994 fn harness_crypto_sha256_is_pure_for_handoff_effects() {
995 let source = r#"fn main(harness: Harness) { harness.crypto.sha256("hello") }"#;
996 let effects = compute_handoff_effects(source, None);
997 assert!(effects.is_empty(), "expected no effects, got {effects:?}");
998 }
999
1000 #[test]
1001 fn harness_stdio_read_line_yields_stdio_read_effect() {
1002 let source = r#"fn main(harness: Harness) { harness.stdio.read_line() }"#;
1003 let effects = compute_handoff_effects(source, None);
1004 assert!(
1005 effects
1006 .iter()
1007 .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1008 && effect.scope == EffectScope::Read),
1009 "expected Stdio read effect, got {effects:?}"
1010 );
1011 }
1012
1013 #[test]
1014 fn llm_call_emits_llm_effect_with_provider_and_model() {
1015 let source = r#"fn main() {
1016 llm_call("summarize", { provider: "anthropic", model: "claude-3-5-sonnet" })
1017 }"#;
1018 let effects = compute_handoff_effects(source, None);
1019 let llm = effects
1020 .iter()
1021 .find(|effect| matches!(effect.kind, EffectKind::Llm { .. }))
1022 .expect("llm effect");
1023 let EffectKind::Llm { provider, model } = &llm.kind else {
1024 panic!("expected llm kind, got {:?}", llm.kind);
1025 };
1026 assert_eq!(provider.as_deref(), Some("anthropic"));
1027 assert_eq!(model.as_deref(), Some("claude-3-5-sonnet"));
1028 }
1029
1030 #[test]
1031 fn harness_llm_catalog_yields_read_effect() {
1032 let source = r#"fn main(harness: Harness) {
1033 harness.llm.catalog()
1034 harness.llm.providers()
1035 }"#;
1036 let effects = compute_handoff_effects(source, None);
1037 assert!(
1038 effects
1039 .iter()
1040 .any(|effect| matches!(effect.kind, EffectKind::Llm { .. })
1041 && effect.scope == EffectScope::Read),
1042 "expected LLM read effect, got {effects:?}"
1043 );
1044 }
1045
1046 #[test]
1047 fn ceiling_drops_disallowed_capabilities() {
1048 let source = r#"fn main(harness: Harness) {
1049 harness.net.get("https://example.test")
1050 harness.fs.read_file("/tmp/in")
1051 }"#;
1052 let mut ceiling = CapabilityPolicy::default();
1053 ceiling
1054 .capabilities
1055 .insert("workspace".to_string(), vec!["read_text".to_string()]);
1056 let effects = compute_handoff_effects(source, Some(&ceiling));
1057 assert!(
1058 effects
1059 .iter()
1060 .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1061 "ceiling without `network` should drop Net effect, got {effects:?}"
1062 );
1063 assert!(
1064 effects
1065 .iter()
1066 .any(|effect| matches!(effect.kind, EffectKind::Fs)),
1067 "ceiling with workspace.read_text should keep Fs read, got {effects:?}"
1068 );
1069 }
1070
1071 #[test]
1072 fn ceiling_side_effect_level_clamps_writes() {
1073 let source = r#"fn main(harness: Harness) {
1074 harness.net.get("https://example.test")
1075 __io_println("hi")
1076 }"#;
1077 let ceiling = CapabilityPolicy {
1078 side_effect_level: Some("read_only".to_string()),
1079 ..Default::default()
1080 };
1081 let effects = compute_handoff_effects(source, Some(&ceiling));
1082 assert!(
1083 effects
1084 .iter()
1085 .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1086 "read_only ceiling must drop Net write, got {effects:?}"
1087 );
1088 assert!(
1089 effects
1090 .iter()
1091 .any(|effect| matches!(effect.kind, EffectKind::Stdio)),
1092 "stdio observe should pass read_only ceiling, got {effects:?}"
1093 );
1094 }
1095
1096 #[test]
1097 fn effect_record_round_trips_through_serde() {
1098 let effects = vec![
1099 EffectRecord::new(EffectKind::Net, EffectScope::Write)
1100 .with_resource("https://api.example/v1"),
1101 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1102 EffectRecord::new(
1103 EffectKind::Llm {
1104 provider: Some("anthropic".to_string()),
1105 model: Some("claude-3-7-sonnet".to_string()),
1106 },
1107 EffectScope::Write,
1108 ),
1109 EffectRecord::new(
1110 EffectKind::Tool {
1111 name: "search".to_string(),
1112 },
1113 EffectScope::Read,
1114 ),
1115 ];
1116 let encoded = serde_json::to_string(&effects).expect("encode");
1117 let decoded: Vec<EffectRecord> = serde_json::from_str(&encoded).expect("decode");
1118 assert_eq!(decoded, effects);
1119 }
1120
1121 #[test]
1122 fn empty_source_returns_no_effects() {
1123 let effects = compute_handoff_effects("fn main() {}", None);
1124 assert!(effects.is_empty(), "got {effects:?}");
1125 }
1126
1127 #[test]
1128 fn effects_from_metadata_round_trips_typed_payload() {
1129 let effects = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1130 .with_resource("https://api.example")];
1131 let mut metadata: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1132 metadata.insert(
1133 "effects".to_string(),
1134 serde_json::to_value(&effects).expect("encode"),
1135 );
1136 assert_eq!(effects_from_metadata(&metadata), effects);
1137 }
1138
1139 #[test]
1140 fn subset_violations_returns_empty_when_child_covered() {
1141 let parent = vec![
1142 EffectRecord::new(EffectKind::Net, EffectScope::Write),
1143 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1144 ];
1145 let child = vec![
1146 EffectRecord::new(EffectKind::Net, EffectScope::Write)
1147 .with_resource("https://example.test"),
1148 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1149 ];
1150 assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1151 }
1152
1153 #[test]
1154 fn subset_violations_flags_unmatched_kinds() {
1155 let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1156 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1157 .with_resource("https://example.test")];
1158 let violations = effect_subset_violations(Some(&parent), &child);
1159 assert_eq!(violations.len(), 1);
1160 assert!(matches!(violations[0].kind, EffectKind::Net));
1161 }
1162
1163 #[test]
1164 fn subset_violations_flags_scope_escalations() {
1165 let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1166 let child = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)];
1167 let violations = effect_subset_violations(Some(&parent), &child);
1168 assert_eq!(violations.len(), 1);
1169 assert_eq!(violations[0].scope, EffectScope::Mutate);
1170 }
1171
1172 #[test]
1173 fn subset_violations_treats_missing_parent_resource_as_wildcard() {
1174 let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1175 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1176 .with_resource("https://api.example/v1")];
1177 assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1178 }
1179
1180 #[test]
1181 fn subset_violations_requires_resource_match_when_parent_declares_one() {
1182 let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1183 .with_resource("https://allowed.test")];
1184 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1185 .with_resource("https://disallowed.test")];
1186 let violations = effect_subset_violations(Some(&parent), &child);
1187 assert_eq!(violations.len(), 1);
1188 }
1189
1190 #[test]
1191 fn subset_violations_skip_when_parent_is_none() {
1192 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1193 assert!(effect_subset_violations(None, &child).is_empty());
1194 }
1195
1196 #[test]
1197 fn subset_violations_empty_parent_flags_every_child_effect() {
1198 let parent: Vec<EffectRecord> = Vec::new();
1199 let child = vec![
1200 EffectRecord::new(EffectKind::Net, EffectScope::Write),
1201 EffectRecord::new(EffectKind::Fs, EffectScope::Read),
1202 ];
1203 let violations = effect_subset_violations(Some(&parent), &child);
1204 assert_eq!(violations.len(), 2);
1205 }
1206
1207 #[test]
1208 fn subset_violations_empty_child_is_always_allowed() {
1209 let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1210 assert!(effect_subset_violations(Some(&parent), &[]).is_empty());
1211 }
1212
1213 #[test]
1214 fn effect_kind_label_shape() {
1215 assert_eq!(effect_kind_label(&EffectKind::Net), "net");
1216 assert_eq!(
1217 effect_kind_label(&EffectKind::Llm {
1218 provider: Some("anthropic".to_string()),
1219 model: Some("claude-3-7-sonnet".to_string()),
1220 }),
1221 "llm:anthropic/claude-3-7-sonnet"
1222 );
1223 assert_eq!(
1224 effect_kind_label(&EffectKind::Tool {
1225 name: "search".to_string()
1226 }),
1227 "tool:search"
1228 );
1229 }
1230
1231 #[test]
1232 fn effect_record_summary_includes_resource() {
1233 let effect = EffectRecord::new(EffectKind::Net, EffectScope::Write)
1234 .with_resource("https://example.test/api");
1235 assert_eq!(
1236 effect_record_summary(&effect),
1237 "net:write (https://example.test/api)"
1238 );
1239 }
1240
1241 #[test]
1242 fn deduplicates_repeated_effects() {
1243 let source = r#"fn main() {
1244 http_get("https://example.test")
1245 http_get("https://example.test")
1246 http_get("https://example.test")
1247 }"#;
1248 let effects = compute_handoff_effects(source, None);
1249 let net_count = effects
1250 .iter()
1251 .filter(|effect| matches!(effect.kind, EffectKind::Net))
1252 .count();
1253 assert_eq!(net_count, 1, "expected dedup, got {effects:?}");
1254 }
1255}