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