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