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 match value {
783 "none" => 0,
784 "read_only" => 1,
785 "workspace_write" => 2,
786 "process_exec" => 3,
787 "network" => 4,
788 _ => 5,
789 }
790 }
791 rank(requested) > rank(ceiling)
792}
793
794pub fn effects_from_metadata(metadata: &BTreeMap<String, serde_json::Value>) -> Vec<EffectRecord> {
798 metadata
799 .get("effects")
800 .and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
801 .unwrap_or_default()
802}
803
804fn parent_covers_child(parent: &EffectRecord, child: &EffectRecord) -> bool {
814 if !effect_kind_family_matches(&parent.kind, &child.kind) {
815 return false;
816 }
817 if !effect_scope_covers(parent.scope, child.scope) {
818 return false;
819 }
820 match (parent.resource.as_deref(), child.resource.as_deref()) {
821 (Some(""), _) => true,
822 (Some(parent_resource), Some(child_resource)) => parent_resource == child_resource,
823 (Some(_), None) => false,
824 (None, _) => true,
825 }
826}
827
828fn effect_kind_family_matches(parent: &EffectKind, child: &EffectKind) -> bool {
829 match (parent, child) {
830 (EffectKind::Stdio, EffectKind::Stdio)
831 | (EffectKind::Fs, EffectKind::Fs)
832 | (EffectKind::Net, EffectKind::Net)
833 | (EffectKind::Spawn, EffectKind::Spawn) => true,
834 (EffectKind::Llm { .. }, EffectKind::Llm { .. }) => true,
835 (
836 EffectKind::Tool {
837 name: parent_name, ..
838 },
839 EffectKind::Tool {
840 name: child_name, ..
841 },
842 ) => parent_name.is_empty() || parent_name == child_name,
843 (
844 EffectKind::Hostcall {
845 name: parent_name, ..
846 },
847 EffectKind::Hostcall {
848 name: child_name, ..
849 },
850 ) => parent_name.is_empty() || parent_name == child_name,
851 (EffectKind::Persona { id: parent_id }, EffectKind::Persona { id: child_id }) => {
852 parent_id.is_empty() || parent_id == child_id
853 }
854 _ => false,
855 }
856}
857
858fn effect_scope_covers(parent: EffectScope, child: EffectScope) -> bool {
859 fn rank(scope: EffectScope) -> u8 {
860 match scope {
861 EffectScope::Read => 1,
862 EffectScope::Observe => 1,
863 EffectScope::Write => 2,
864 EffectScope::Mutate => 3,
865 }
866 }
867 rank(parent) >= rank(child)
868}
869
870pub fn effect_subset_violations(
877 parent: Option<&[EffectRecord]>,
878 child: &[EffectRecord],
879) -> Vec<EffectRecord> {
880 let Some(parent) = parent else {
881 return Vec::new();
882 };
883 child
884 .iter()
885 .filter(|effect| {
886 !parent
887 .iter()
888 .any(|allowed| parent_covers_child(allowed, effect))
889 })
890 .cloned()
891 .collect()
892}
893
894pub fn effect_kind_label(kind: &EffectKind) -> String {
897 match kind {
898 EffectKind::Stdio => "stdio".to_string(),
899 EffectKind::Fs => "fs".to_string(),
900 EffectKind::Net => "net".to_string(),
901 EffectKind::Llm { provider, model } => match (provider.as_deref(), model.as_deref()) {
902 (Some(provider), Some(model)) => format!("llm:{provider}/{model}"),
903 (Some(provider), None) => format!("llm:{provider}"),
904 (None, Some(model)) => format!("llm:{model}"),
905 (None, None) => "llm".to_string(),
906 },
907 EffectKind::Tool { name } if !name.is_empty() => format!("tool:{name}"),
908 EffectKind::Tool { .. } => "tool".to_string(),
909 EffectKind::Hostcall { name } if !name.is_empty() => format!("hostcall:{name}"),
910 EffectKind::Hostcall { .. } => "hostcall".to_string(),
911 EffectKind::Persona { id } if !id.is_empty() => format!("persona:{id}"),
912 EffectKind::Persona { .. } => "persona".to_string(),
913 EffectKind::Spawn => "spawn".to_string(),
914 }
915}
916
917pub fn effect_record_summary(effect: &EffectRecord) -> String {
919 let scope = match effect.scope {
920 EffectScope::Read => "read",
921 EffectScope::Write => "write",
922 EffectScope::Mutate => "mutate",
923 EffectScope::Observe => "observe",
924 };
925 match effect.resource.as_deref() {
926 Some(resource) if !resource.is_empty() => {
927 format!(
928 "{}:{} ({})",
929 effect_kind_label(&effect.kind),
930 scope,
931 resource
932 )
933 }
934 _ => format!("{}:{}", effect_kind_label(&effect.kind), scope),
935 }
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 #[test]
943 fn harness_net_call_yields_net_effect() {
944 let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test") }"#;
945 let effects = compute_handoff_effects(source, None);
946 assert!(
947 effects
948 .iter()
949 .any(|effect| matches!(effect.kind, EffectKind::Net)
950 && effect.scope == EffectScope::Write),
951 "expected Net write effect, got {effects:?}"
952 );
953 }
954
955 #[test]
956 fn harness_process_spawn_captured_yields_process_hostcall_effect() {
957 let source =
958 r#"fn main(harness: Harness) { harness.process.spawn_captured({cmd: "printf"}) }"#;
959 let effects = compute_handoff_effects(source, None);
960 assert!(
961 effects.iter().any(|effect| {
962 matches!(
963 &effect.kind,
964 EffectKind::Hostcall { name } if name == "process.spawn_captured"
965 ) && effect.scope == EffectScope::Write
966 }),
967 "expected process hostcall write effect, got {effects:?}"
968 );
969 }
970
971 #[test]
972 fn http_get_builtin_yields_net_effect_with_resource() {
973 let source = r#"fn main() { http_get("https://example.test/api") }"#;
974 let effects = compute_handoff_effects(source, None);
975 let net = effects
976 .iter()
977 .find(|effect| matches!(effect.kind, EffectKind::Net))
978 .expect("net effect");
979 assert_eq!(net.scope, EffectScope::Write);
980 assert_eq!(net.resource.as_deref(), Some("https://example.test/api"));
981 }
982
983 #[test]
984 fn unix_socket_json_request_yields_net_effect_with_resource() {
985 let source = r#"fn main() { __net_unix_socket_json_request("/tmp/harn.sock", {}) }"#;
986 let effects = compute_handoff_effects(source, None);
987 let net = effects
988 .iter()
989 .find(|effect| matches!(effect.kind, EffectKind::Net))
990 .expect("net effect");
991 assert_eq!(net.scope, EffectScope::Write);
992 assert_eq!(net.resource.as_deref(), Some("/tmp/harn.sock"));
993 }
994
995 #[test]
996 fn files_upload_yields_fs_read_and_net_write_effects() {
997 let source = r#"fn main() { __files_upload("/tmp/input.pdf", "gemini") }"#;
998 let effects = compute_handoff_effects(source, None);
999 assert!(
1000 effects.iter().any(|effect| {
1001 matches!(effect.kind, EffectKind::Fs)
1002 && effect.scope == EffectScope::Read
1003 && effect.resource.as_deref() == Some("/tmp/input.pdf")
1004 }),
1005 "expected Fs read effect, got {effects:?}"
1006 );
1007 assert!(
1008 effects.iter().any(|effect| {
1009 matches!(effect.kind, EffectKind::Net)
1010 && effect.scope == EffectScope::Write
1011 && effect.resource.as_deref() == Some("gemini")
1012 }),
1013 "expected Net write effect, got {effects:?}"
1014 );
1015 }
1016
1017 #[test]
1018 fn std_files_upload_wrapper_yields_fs_read_and_net_write_effects() {
1019 let source = r#"
1020import { upload } from "std/files"
1021fn main() { upload("/tmp/input.pdf", "gemini") }
1022"#;
1023 let effects = compute_handoff_effects(source, None);
1024 assert!(
1025 effects.iter().any(|effect| {
1026 matches!(effect.kind, EffectKind::Fs)
1027 && effect.scope == EffectScope::Read
1028 && effect.resource.as_deref() == Some("/tmp/input.pdf")
1029 }),
1030 "expected Fs read effect, got {effects:?}"
1031 );
1032 assert!(
1033 effects.iter().any(|effect| {
1034 matches!(effect.kind, EffectKind::Net)
1035 && effect.scope == EffectScope::Write
1036 && effect.resource.as_deref() == Some("gemini")
1037 }),
1038 "expected Net write effect, got {effects:?}"
1039 );
1040 }
1041
1042 #[test]
1043 fn harness_fs_write_yields_fs_write_effect() {
1044 let source = r#"fn main(harness: Harness) { harness.fs.write_file("/tmp/out", "hi") }"#;
1045 let effects = compute_handoff_effects(source, None);
1046 assert!(
1047 effects
1048 .iter()
1049 .any(|effect| matches!(effect.kind, EffectKind::Fs)
1050 && effect.scope == EffectScope::Write),
1051 "expected Fs write effect, got {effects:?}"
1052 );
1053 }
1054
1055 #[test]
1056 fn harness_term_read_password_yields_stdio_read_effect() {
1057 let source = r#"fn main(harness: Harness) { harness.term.read_password("password: ") }"#;
1058 let effects = compute_handoff_effects(source, None);
1059 assert!(
1060 effects
1061 .iter()
1062 .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1063 && effect.scope == EffectScope::Read),
1064 "expected Stdio read effect, got {effects:?}"
1065 );
1066 }
1067
1068 #[test]
1069 fn harness_fs_mkdtemp_yields_fs_write_effect() {
1070 let source = r#"fn main(harness: Harness) { harness.fs.mkdtemp("harn-") }"#;
1071 let effects = compute_handoff_effects(source, None);
1072 assert!(
1073 effects
1074 .iter()
1075 .any(|effect| matches!(effect.kind, EffectKind::Fs)
1076 && effect.scope == EffectScope::Write),
1077 "expected Fs write effect, got {effects:?}"
1078 );
1079 }
1080
1081 #[test]
1082 fn harness_crypto_sha256_is_pure_for_handoff_effects() {
1083 let source = r#"fn main(harness: Harness) { harness.crypto.sha256("hello") }"#;
1084 let effects = compute_handoff_effects(source, None);
1085 assert!(effects.is_empty(), "expected no effects, got {effects:?}");
1086 }
1087
1088 #[test]
1089 fn harness_stdio_read_line_yields_stdio_read_effect() {
1090 let source = r"fn main(harness: Harness) { harness.stdio.read_line() }";
1091 let effects = compute_handoff_effects(source, None);
1092 assert!(
1093 effects
1094 .iter()
1095 .any(|effect| matches!(effect.kind, EffectKind::Stdio)
1096 && effect.scope == EffectScope::Read),
1097 "expected Stdio read effect, got {effects:?}"
1098 );
1099 }
1100
1101 #[test]
1102 fn llm_call_emits_llm_effect_with_provider_and_model() {
1103 let source = r#"fn main() {
1104 llm_call("summarize", { provider: "anthropic", model: "claude-3-5-sonnet" })
1105 }"#;
1106 let effects = compute_handoff_effects(source, None);
1107 let llm = effects
1108 .iter()
1109 .find(|effect| matches!(effect.kind, EffectKind::Llm { .. }))
1110 .expect("llm effect");
1111 let EffectKind::Llm { provider, model } = &llm.kind else {
1112 panic!("expected llm kind, got {:?}", llm.kind);
1113 };
1114 assert_eq!(provider.as_deref(), Some("anthropic"));
1115 assert_eq!(model.as_deref(), Some("claude-3-5-sonnet"));
1116 }
1117
1118 #[test]
1119 fn harness_llm_catalog_yields_read_effect() {
1120 let source = r"fn main(harness: Harness) {
1121 harness.llm.catalog()
1122 harness.llm.providers()
1123 }";
1124 let effects = compute_handoff_effects(source, None);
1125 assert!(
1126 effects
1127 .iter()
1128 .any(|effect| matches!(effect.kind, EffectKind::Llm { .. })
1129 && effect.scope == EffectScope::Read),
1130 "expected LLM read effect, got {effects:?}"
1131 );
1132 }
1133
1134 #[test]
1135 fn ceiling_drops_disallowed_capabilities() {
1136 let source = r#"fn main(harness: Harness) {
1137 harness.net.get("https://example.test")
1138 harness.fs.read_file("/tmp/in")
1139 }"#;
1140 let mut ceiling = CapabilityPolicy::default();
1141 ceiling
1142 .capabilities
1143 .insert("workspace".to_string(), vec!["read_text".to_string()]);
1144 let effects = compute_handoff_effects(source, Some(&ceiling));
1145 assert!(
1146 effects
1147 .iter()
1148 .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1149 "ceiling without `network` should drop Net effect, got {effects:?}"
1150 );
1151 assert!(
1152 effects
1153 .iter()
1154 .any(|effect| matches!(effect.kind, EffectKind::Fs)),
1155 "ceiling with workspace.read_text should keep Fs read, got {effects:?}"
1156 );
1157 }
1158
1159 #[test]
1160 fn ceiling_side_effect_level_clamps_writes() {
1161 let source = r#"fn main(harness: Harness) {
1162 harness.net.get("https://example.test")
1163 __io_println("hi")
1164 }"#;
1165 let ceiling = CapabilityPolicy {
1166 side_effect_level: Some("read_only".to_string()),
1167 ..Default::default()
1168 };
1169 let effects = compute_handoff_effects(source, Some(&ceiling));
1170 assert!(
1171 effects
1172 .iter()
1173 .all(|effect| !matches!(effect.kind, EffectKind::Net)),
1174 "read_only ceiling must drop Net write, got {effects:?}"
1175 );
1176 assert!(
1177 effects
1178 .iter()
1179 .any(|effect| matches!(effect.kind, EffectKind::Stdio)),
1180 "stdio observe should pass read_only ceiling, got {effects:?}"
1181 );
1182 }
1183
1184 #[test]
1185 fn effect_record_round_trips_through_serde() {
1186 let effects = vec![
1187 EffectRecord::new(EffectKind::Net, EffectScope::Write)
1188 .with_resource("https://api.example/v1"),
1189 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
1190 EffectRecord::new(
1191 EffectKind::Llm {
1192 provider: Some("anthropic".to_string()),
1193 model: Some("claude-3-7-sonnet".to_string()),
1194 },
1195 EffectScope::Write,
1196 ),
1197 EffectRecord::new(
1198 EffectKind::Tool {
1199 name: "search".to_string(),
1200 },
1201 EffectScope::Read,
1202 ),
1203 ];
1204 let encoded = serde_json::to_string(&effects).expect("encode");
1205 let decoded: Vec<EffectRecord> = serde_json::from_str(&encoded).expect("decode");
1206 assert_eq!(decoded, effects);
1207 }
1208
1209 #[test]
1210 fn empty_source_returns_no_effects() {
1211 let effects = compute_handoff_effects("fn main() {}", None);
1212 assert!(effects.is_empty(), "got {effects:?}");
1213 }
1214
1215 #[test]
1216 fn effects_from_metadata_round_trips_typed_payload() {
1217 let effects = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1218 .with_resource("https://api.example")];
1219 let mut metadata: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1220 metadata.insert(
1221 "effects".to_string(),
1222 serde_json::to_value(&effects).expect("encode"),
1223 );
1224 assert_eq!(effects_from_metadata(&metadata), effects);
1225 }
1226
1227 #[test]
1228 fn subset_violations_returns_empty_when_child_covered() {
1229 let parent = vec![
1230 EffectRecord::new(EffectKind::Net, EffectScope::Write),
1231 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1232 ];
1233 let child = vec![
1234 EffectRecord::new(EffectKind::Net, EffectScope::Write)
1235 .with_resource("https://example.test"),
1236 EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace"),
1237 ];
1238 assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1239 }
1240
1241 #[test]
1242 fn subset_violations_flags_unmatched_kinds() {
1243 let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1244 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1245 .with_resource("https://example.test")];
1246 let violations = effect_subset_violations(Some(&parent), &child);
1247 assert_eq!(violations.len(), 1);
1248 assert!(matches!(violations[0].kind, EffectKind::Net));
1249 }
1250
1251 #[test]
1252 fn subset_violations_flags_scope_escalations() {
1253 let parent = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Read)];
1254 let child = vec![EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)];
1255 let violations = effect_subset_violations(Some(&parent), &child);
1256 assert_eq!(violations.len(), 1);
1257 assert_eq!(violations[0].scope, EffectScope::Mutate);
1258 }
1259
1260 #[test]
1261 fn subset_violations_treats_missing_parent_resource_as_wildcard() {
1262 let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1263 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1264 .with_resource("https://api.example/v1")];
1265 assert!(effect_subset_violations(Some(&parent), &child).is_empty());
1266 }
1267
1268 #[test]
1269 fn subset_violations_requires_resource_match_when_parent_declares_one() {
1270 let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1271 .with_resource("https://allowed.test")];
1272 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
1273 .with_resource("https://disallowed.test")];
1274 let violations = effect_subset_violations(Some(&parent), &child);
1275 assert_eq!(violations.len(), 1);
1276 }
1277
1278 #[test]
1279 fn subset_violations_skip_when_parent_is_none() {
1280 let child = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1281 assert!(effect_subset_violations(None, &child).is_empty());
1282 }
1283
1284 #[test]
1285 fn subset_violations_empty_parent_flags_every_child_effect() {
1286 let parent: Vec<EffectRecord> = Vec::new();
1287 let child = vec![
1288 EffectRecord::new(EffectKind::Net, EffectScope::Write),
1289 EffectRecord::new(EffectKind::Fs, EffectScope::Read),
1290 ];
1291 let violations = effect_subset_violations(Some(&parent), &child);
1292 assert_eq!(violations.len(), 2);
1293 }
1294
1295 #[test]
1296 fn subset_violations_empty_child_is_always_allowed() {
1297 let parent = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)];
1298 assert!(effect_subset_violations(Some(&parent), &[]).is_empty());
1299 }
1300
1301 #[test]
1302 fn effect_kind_label_shape() {
1303 assert_eq!(effect_kind_label(&EffectKind::Net), "net");
1304 assert_eq!(
1305 effect_kind_label(&EffectKind::Llm {
1306 provider: Some("anthropic".to_string()),
1307 model: Some("claude-3-7-sonnet".to_string()),
1308 }),
1309 "llm:anthropic/claude-3-7-sonnet"
1310 );
1311 assert_eq!(
1312 effect_kind_label(&EffectKind::Tool {
1313 name: "search".to_string()
1314 }),
1315 "tool:search"
1316 );
1317 }
1318
1319 #[test]
1320 fn effect_record_summary_includes_resource() {
1321 let effect = EffectRecord::new(EffectKind::Net, EffectScope::Write)
1322 .with_resource("https://example.test/api");
1323 assert_eq!(
1324 effect_record_summary(&effect),
1325 "net:write (https://example.test/api)"
1326 );
1327 }
1328
1329 #[test]
1330 fn deduplicates_repeated_effects() {
1331 let source = r#"fn main() {
1332 http_get("https://example.test")
1333 http_get("https://example.test")
1334 http_get("https://example.test")
1335 }"#;
1336 let effects = compute_handoff_effects(source, None);
1337 let net_count = effects
1338 .iter()
1339 .filter(|effect| matches!(effect.kind, EffectKind::Net))
1340 .count();
1341 assert_eq!(net_count, 1, "expected dedup, got {effects:?}");
1342 }
1343}