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