use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use harn_ir::{CallClassification, Capability, LiteralValue, NodeSemantics};
use harn_parser::{Node, SNode};
use super::CapabilityPolicy;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EffectKind {
Stdio,
Fs,
Net,
Llm {
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
},
Tool { name: String },
Hostcall { name: String },
Persona { id: String },
Spawn,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
#[serde(rename_all = "snake_case")]
pub enum EffectScope {
Read,
Write,
Mutate,
Observe,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd, Hash)]
pub struct EffectRecord {
pub kind: EffectKind,
pub scope: EffectScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<String>,
}
impl EffectRecord {
pub fn new(kind: EffectKind, scope: EffectScope) -> Self {
Self {
kind,
scope,
resource: None,
}
}
pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
let resource = resource.into();
self.resource = if resource.is_empty() {
None
} else {
Some(resource)
};
self
}
}
pub fn compute_handoff_effects(
source: &str,
ceiling: Option<&CapabilityPolicy>,
) -> Vec<EffectRecord> {
let Ok(program) = harn_parser::parse_source(source) else {
return Vec::new();
};
let mut collected: BTreeSet<EffectRecord> = BTreeSet::new();
let report = harn_ir::analyze_program(&program);
for handler in &report.handlers {
for node in &handler.nodes {
let NodeSemantics::Call(call) = &node.semantics else {
continue;
};
for effect in effects_from_call(call) {
collected.insert(effect);
}
}
}
for node in &program {
walk_for_harness_effects(node, &mut collected);
}
let mut effects: Vec<EffectRecord> = collected.into_iter().collect();
if let Some(ceiling) = ceiling {
effects.retain(|effect| effect_allowed_by_ceiling(effect, ceiling));
}
effects
}
fn effects_from_call(call: &harn_ir::CallSemantics) -> Vec<EffectRecord> {
if let Some(effect) = builtin_effect(&call.name) {
return vec![annotate_with_resource(effect, call)];
}
if call.name == "host_call" {
if let Some(operation) = call.literal_args.first().and_then(literal_as_str) {
return vec![EffectRecord::new(
EffectKind::Hostcall {
name: operation.to_string(),
},
hostcall_scope(operation),
)];
}
}
if let CallClassification::Capabilities(capability_effects) = &call.classification {
return capability_effects
.iter()
.filter_map(capability_effect_to_record)
.collect();
}
Vec::new()
}
fn builtin_effect(name: &str) -> Option<EffectRecord> {
match name {
"print" | "println" | "eprint" | "eprintln" | "write_stdout" | "write_stderr" => {
Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Observe))
}
"read_stdin" => Some(EffectRecord::new(EffectKind::Stdio, EffectScope::Read)),
"read_file" | "read_file_bytes" | "read_file_result" | "read_lines" | "list_dir"
| "walk_dir" | "glob" | "file_exists" | "stat" => {
Some(EffectRecord::new(EffectKind::Fs, EffectScope::Read))
}
"write_file" | "write_file_bytes" | "append_file" | "mkdir" | "copy_file" | "move_file" => {
Some(EffectRecord::new(EffectKind::Fs, EffectScope::Write))
}
"delete_file" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
"apply_edit" => Some(EffectRecord::new(EffectKind::Fs, EffectScope::Mutate)),
"http_get"
| "http_post"
| "http_put"
| "http_patch"
| "http_delete"
| "http_request"
| "http_download"
| "http_session"
| "http_session_request"
| "http_session_close"
| "http_stream_open"
| "http_stream_read"
| "http_stream_close"
| "sse_connect"
| "sse_receive"
| "sse_close"
| "sse_server_response"
| "sse_server_send"
| "sse_server_heartbeat"
| "sse_server_flush"
| "sse_server_close"
| "sse_server_cancel"
| "websocket_connect"
| "websocket_accept"
| "websocket_send"
| "websocket_receive"
| "websocket_close"
| "websocket_route"
| "websocket_server"
| "websocket_server_close" => Some(EffectRecord::new(EffectKind::Net, EffectScope::Write)),
"llm_call"
| "llm_call_safe"
| "llm_stream_call"
| "llm_call_structured"
| "llm_call_structured_safe"
| "llm_call_structured_result"
| "llm_completion"
| "agent_llm_turn"
| "agent_turn"
| "agent_loop" => Some(EffectRecord::new(
EffectKind::Llm {
provider: None,
model: None,
},
EffectScope::Write,
)),
"spawn_agent"
| "send_input"
| "resume_agent"
| "wait_agent"
| "close_agent"
| "worker_trigger"
| "__host_sub_agent_run"
| "__host_worker_spawn"
| "__host_worker_send_input"
| "__host_worker_resume"
| "__host_worker_trigger"
| "__host_worker_wait"
| "__host_worker_close" => Some(EffectRecord::new(EffectKind::Spawn, EffectScope::Write)),
"tool_call" | "host_tool_call" => Some(EffectRecord::new(
EffectKind::Tool {
name: String::new(),
},
EffectScope::Write,
)),
_ => None,
}
}
fn annotate_with_resource(mut effect: EffectRecord, call: &harn_ir::CallSemantics) -> EffectRecord {
match &mut effect.kind {
EffectKind::Llm { provider, model } => {
for arg in &call.literal_args {
if let LiteralValue::Dict(entries) = arg {
if let Some(value) = entries.get("provider").and_then(literal_as_str) {
*provider = Some(value.to_string());
}
if let Some(value) = entries.get("model").and_then(literal_as_str) {
*model = Some(value.to_string());
}
}
}
}
EffectKind::Tool { name } => {
if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
*name = value.to_string();
}
}
_ => {
if let Some(value) = call.literal_args.first().and_then(literal_as_str) {
effect.resource = Some(value.to_string());
}
}
}
effect
}
fn capability_effect_to_record(effect: &harn_ir::CapabilityEffect) -> Option<EffectRecord> {
let (kind, scope) = match effect.capability {
Capability::WorkspaceMutation => (EffectKind::Fs, EffectScope::Mutate),
Capability::CommandExecution => (
EffectKind::Hostcall {
name: format!("process.{}", effect.operation),
},
EffectScope::Write,
),
Capability::NetworkAccess => (EffectKind::Net, EffectScope::Write),
Capability::ConnectorAccess => (
EffectKind::Hostcall {
name: if effect.operation.is_empty() {
"connector.call".to_string()
} else {
format!("connector.{}", effect.operation)
},
},
EffectScope::Write,
),
Capability::ModelCall => (
EffectKind::Llm {
provider: None,
model: None,
},
EffectScope::Write,
),
Capability::WorkerDispatch => (EffectKind::Spawn, EffectScope::Write),
Capability::HumanApproval => return None,
Capability::AutonomyPolicy => return None,
};
let resource = effect.path.clone();
Some(EffectRecord {
kind,
scope,
resource,
})
}
fn hostcall_scope(operation: &str) -> EffectScope {
match operation {
op if op.starts_with("workspace.read") || op.starts_with("workspace.list") => {
EffectScope::Read
}
op if op.starts_with("workspace.write") || op == "workspace.apply_edit" => {
EffectScope::Mutate
}
op if op.starts_with("process.") => EffectScope::Write,
_ => EffectScope::Write,
}
}
fn literal_as_str(value: &LiteralValue) -> Option<&str> {
match value {
LiteralValue::String(value) | LiteralValue::Identifier(value) => Some(value.as_str()),
_ => None,
}
}
fn walk_for_harness_effects(node: &SNode, out: &mut BTreeSet<EffectRecord>) {
if let Some(effect) = harness_method_effect(node) {
out.insert(effect);
}
for child in child_nodes(node) {
walk_for_harness_effects(child, out);
}
}
fn harness_method_effect(node: &SNode) -> Option<EffectRecord> {
let (object, method) = match &node.node {
Node::MethodCall { object, method, .. }
| Node::OptionalMethodCall { object, method, .. } => (object, method),
_ => return None,
};
let (sub_handle, root) = harness_sub_handle(object)?;
if !is_harness_root(root) {
return None;
}
let (kind, scope) = match (sub_handle.as_str(), method.as_str()) {
("stdio", "print" | "println" | "eprint" | "eprintln") => {
(EffectKind::Stdio, EffectScope::Observe)
}
("stdio", "read_line" | "prompt") => (EffectKind::Stdio, EffectScope::Read),
("clock", _) => return None,
("env", "set" | "unset") => (
EffectKind::Hostcall {
name: "env.set".to_string(),
},
EffectScope::Mutate,
),
("env", _) => (
EffectKind::Hostcall {
name: "env.get".to_string(),
},
EffectScope::Read,
),
("random", _) => return None,
("fs", "read_file" | "read_text" | "read" | "exists" | "list_dir" | "stat") => {
(EffectKind::Fs, EffectScope::Read)
}
("fs", "write_file" | "write_text" | "append_file" | "mkdir" | "copy_file") => {
(EffectKind::Fs, EffectScope::Write)
}
("fs", "delete_file" | "delete" | "remove") => (EffectKind::Fs, EffectScope::Mutate),
("fs", _) => (EffectKind::Fs, EffectScope::Read),
("net", _) => (EffectKind::Net, EffectScope::Write),
_ => return None,
};
Some(EffectRecord::new(kind, scope))
}
fn harness_sub_handle(node: &SNode) -> Option<(String, &SNode)> {
match &node.node {
Node::PropertyAccess { object, property }
| Node::OptionalPropertyAccess { object, property } => {
Some((property.clone(), object.as_ref()))
}
_ => None,
}
}
fn is_harness_root(node: &SNode) -> bool {
matches!(&node.node, Node::Identifier(name) if name == "harness")
}
fn child_nodes(node: &SNode) -> Vec<&SNode> {
let mut children: Vec<&SNode> = Vec::new();
match &node.node {
Node::AttributedDecl { inner, .. } => children.push(inner.as_ref()),
Node::Pipeline { body, .. }
| Node::FnDecl { body, .. }
| Node::ToolDecl { body, .. }
| Node::SpawnExpr { body }
| Node::Retry { body, .. }
| Node::TryExpr { body }
| Node::DeferStmt { body }
| Node::MutexBlock { body }
| Node::Block(body)
| Node::OverrideDecl { body, .. } => children.extend(body.iter()),
Node::ImplBlock { methods, .. } => children.extend(methods.iter()),
Node::IfElse {
condition,
then_body,
else_body,
} => {
children.push(condition.as_ref());
children.extend(then_body.iter());
if let Some(else_body) = else_body.as_ref() {
children.extend(else_body.iter());
}
}
Node::ForIn { iterable, body, .. } => {
children.push(iterable.as_ref());
children.extend(body.iter());
}
Node::WhileLoop { condition, body } => {
children.push(condition.as_ref());
children.extend(body.iter());
}
Node::MatchExpr { value, arms } => {
children.push(value.as_ref());
for arm in arms {
if let Some(guard) = arm.guard.as_ref() {
children.push(guard.as_ref());
}
children.extend(arm.body.iter());
}
}
Node::CostRoute { options, body } => {
for (_key, value) in options {
children.push(value);
}
children.extend(body.iter());
}
Node::ReturnStmt { value } => {
if let Some(value) = value.as_ref() {
children.push(value.as_ref());
}
}
Node::ThrowStmt { value } => children.push(value.as_ref()),
Node::TryCatch {
body,
catch_body,
finally_body,
..
} => {
children.extend(body.iter());
children.extend(catch_body.iter());
if let Some(finally_body) = finally_body.as_ref() {
children.extend(finally_body.iter());
}
}
Node::SkillDecl { fields, .. } => {
for (_name, value) in fields {
children.push(value);
}
}
Node::EvalPackDecl {
fields,
body,
summarize,
..
} => {
for (_name, value) in fields {
children.push(value);
}
children.extend(body.iter());
if let Some(summarize) = summarize.as_ref() {
children.extend(summarize.iter());
}
}
Node::LetBinding { value, .. } | Node::VarBinding { value, .. } => {
children.push(value.as_ref());
}
Node::DeadlineBlock { duration, body } => {
children.push(duration.as_ref());
children.extend(body.iter());
}
Node::YieldExpr { value } => {
if let Some(value) = value.as_ref() {
children.push(value.as_ref());
}
}
Node::EmitExpr { value } => children.push(value.as_ref()),
Node::GuardStmt {
condition,
else_body,
} => {
children.push(condition.as_ref());
children.extend(else_body.iter());
}
Node::RequireStmt { condition, message } => {
children.push(condition.as_ref());
if let Some(message) = message.as_ref() {
children.push(message.as_ref());
}
}
Node::HitlExpr { args, .. } => {
for arg in args {
children.push(&arg.value);
}
}
Node::Parallel {
expr,
body,
options,
..
} => {
children.push(expr.as_ref());
children.extend(body.iter());
for (_key, value) in options {
children.push(value);
}
}
Node::SelectExpr {
cases,
timeout,
default_body,
} => {
for case in cases {
children.push(case.channel.as_ref());
children.extend(case.body.iter());
}
if let Some((duration, body)) = timeout.as_ref() {
children.push(duration.as_ref());
children.extend(body.iter());
}
if let Some(body) = default_body.as_ref() {
children.extend(body.iter());
}
}
Node::FunctionCall { args, .. } => children.extend(args.iter()),
Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
children.push(object.as_ref());
children.extend(args.iter());
}
Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
children.push(object.as_ref());
}
Node::SubscriptAccess { object, index }
| Node::OptionalSubscriptAccess { object, index } => {
children.push(object.as_ref());
children.push(index.as_ref());
}
Node::SliceAccess { object, start, end } => {
children.push(object.as_ref());
if let Some(start) = start.as_ref() {
children.push(start.as_ref());
}
if let Some(end) = end.as_ref() {
children.push(end.as_ref());
}
}
Node::BinaryOp { left, right, .. } => {
children.push(left.as_ref());
children.push(right.as_ref());
}
Node::UnaryOp { operand, .. } => children.push(operand.as_ref()),
Node::Ternary {
condition,
true_expr,
false_expr,
} => {
children.push(condition.as_ref());
children.push(true_expr.as_ref());
children.push(false_expr.as_ref());
}
Node::Assignment { target, value, .. } => {
children.push(target.as_ref());
children.push(value.as_ref());
}
Node::EnumConstruct { args, .. } => children.extend(args.iter()),
Node::StructConstruct { fields, .. } => {
for entry in fields {
children.push(&entry.key);
children.push(&entry.value);
}
}
Node::ListLiteral(items) => children.extend(items.iter()),
Node::DictLiteral(entries) => {
for entry in entries {
children.push(&entry.key);
children.push(&entry.value);
}
}
Node::Spread(inner) => children.push(inner.as_ref()),
Node::TryOperator { operand } | Node::TryStar { operand } => {
children.push(operand.as_ref());
}
Node::OrPattern(items) => children.extend(items.iter()),
Node::Closure { body, .. } => children.extend(body.iter()),
Node::RangeExpr { start, end, .. } => {
children.push(start.as_ref());
children.push(end.as_ref());
}
_ => {}
}
children
}
fn effect_allowed_by_ceiling(effect: &EffectRecord, ceiling: &CapabilityPolicy) -> bool {
if !ceiling.capabilities.is_empty() {
let (capability, op) = effect_capability_op(effect);
let allowed = ceiling
.capabilities
.get(capability)
.is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op));
if !allowed {
return false;
}
}
if let Some(ceiling_level) = ceiling.side_effect_level.as_deref() {
let requested = side_effect_level_for(effect);
if requested_exceeds_ceiling(requested, ceiling_level) {
return false;
}
}
true
}
fn effect_capability_op(effect: &EffectRecord) -> (&'static str, &'static str) {
match (&effect.kind, effect.scope) {
(EffectKind::Stdio, EffectScope::Read) => ("stdio", "read"),
(EffectKind::Stdio, _) => ("stdio", "write"),
(EffectKind::Fs, EffectScope::Read) => ("workspace", "read_text"),
(EffectKind::Fs, EffectScope::Write) => ("workspace", "write_text"),
(EffectKind::Fs, EffectScope::Mutate) => ("workspace", "apply_edit"),
(EffectKind::Fs, EffectScope::Observe) => ("workspace", "exists"),
(EffectKind::Net, _) => ("network", "http"),
(EffectKind::Llm { .. }, _) => ("llm", "call"),
(EffectKind::Tool { .. }, _) => ("host", "tool_call"),
(EffectKind::Hostcall { .. }, _) => ("connector", "call"),
(EffectKind::Persona { .. }, _) => ("worker", "dispatch"),
(EffectKind::Spawn, _) => ("worker", "dispatch"),
}
}
fn side_effect_level_for(effect: &EffectRecord) -> &'static str {
match (&effect.kind, effect.scope) {
(EffectKind::Stdio, _) => "read_only",
(EffectKind::Fs, EffectScope::Read | EffectScope::Observe) => "read_only",
(EffectKind::Fs, _) => "workspace_write",
(EffectKind::Net, _) => "network",
(EffectKind::Llm { .. }, _) => "network",
(EffectKind::Tool { .. }, _) => "workspace_write",
(EffectKind::Hostcall { name }, _) if name.starts_with("process.") => "process_exec",
(EffectKind::Hostcall { .. }, _) => "read_only",
(EffectKind::Persona { .. }, _) => "workspace_write",
(EffectKind::Spawn, _) => "workspace_write",
}
}
fn requested_exceeds_ceiling(requested: &str, ceiling: &str) -> bool {
fn rank(value: &str) -> usize {
match value {
"none" => 0,
"read_only" => 1,
"workspace_write" => 2,
"process_exec" => 3,
"network" => 4,
_ => 5,
}
}
rank(requested) > rank(ceiling)
}
pub fn effects_from_metadata(metadata: &BTreeMap<String, serde_json::Value>) -> Vec<EffectRecord> {
metadata
.get("effects")
.and_then(|value| serde_json::from_value::<Vec<EffectRecord>>(value.clone()).ok())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn harness_net_call_yields_net_effect() {
let source = r#"fn main(harness: Harness) { harness.net.get("https://example.test") }"#;
let effects = compute_handoff_effects(source, None);
assert!(
effects
.iter()
.any(|effect| matches!(effect.kind, EffectKind::Net)
&& effect.scope == EffectScope::Write),
"expected Net write effect, got {effects:?}"
);
}
#[test]
fn http_get_builtin_yields_net_effect_with_resource() {
let source = r#"fn main() { http_get("https://example.test/api") }"#;
let effects = compute_handoff_effects(source, None);
let net = effects
.iter()
.find(|effect| matches!(effect.kind, EffectKind::Net))
.expect("net effect");
assert_eq!(net.scope, EffectScope::Write);
assert_eq!(net.resource.as_deref(), Some("https://example.test/api"));
}
#[test]
fn harness_fs_write_yields_fs_write_effect() {
let source = r#"fn main(harness: Harness) { harness.fs.write_file("/tmp/out", "hi") }"#;
let effects = compute_handoff_effects(source, None);
assert!(
effects
.iter()
.any(|effect| matches!(effect.kind, EffectKind::Fs)
&& effect.scope == EffectScope::Write),
"expected Fs write effect, got {effects:?}"
);
}
#[test]
fn llm_call_emits_llm_effect_with_provider_and_model() {
let source = r#"fn main() {
llm_call("summarize", { provider: "anthropic", model: "claude-3-5-sonnet" })
}"#;
let effects = compute_handoff_effects(source, None);
let llm = effects
.iter()
.find(|effect| matches!(effect.kind, EffectKind::Llm { .. }))
.expect("llm effect");
let EffectKind::Llm { provider, model } = &llm.kind else {
panic!("expected llm kind, got {:?}", llm.kind);
};
assert_eq!(provider.as_deref(), Some("anthropic"));
assert_eq!(model.as_deref(), Some("claude-3-5-sonnet"));
}
#[test]
fn ceiling_drops_disallowed_capabilities() {
let source = r#"fn main(harness: Harness) {
harness.net.get("https://example.test")
harness.fs.read_file("/tmp/in")
}"#;
let mut ceiling = CapabilityPolicy::default();
ceiling
.capabilities
.insert("workspace".to_string(), vec!["read_text".to_string()]);
let effects = compute_handoff_effects(source, Some(&ceiling));
assert!(
effects
.iter()
.all(|effect| !matches!(effect.kind, EffectKind::Net)),
"ceiling without `network` should drop Net effect, got {effects:?}"
);
assert!(
effects
.iter()
.any(|effect| matches!(effect.kind, EffectKind::Fs)),
"ceiling with workspace.read_text should keep Fs read, got {effects:?}"
);
}
#[test]
fn ceiling_side_effect_level_clamps_writes() {
let source = r#"fn main(harness: Harness) {
harness.net.get("https://example.test")
println("hi")
}"#;
let ceiling = CapabilityPolicy {
side_effect_level: Some("read_only".to_string()),
..Default::default()
};
let effects = compute_handoff_effects(source, Some(&ceiling));
assert!(
effects
.iter()
.all(|effect| !matches!(effect.kind, EffectKind::Net)),
"read_only ceiling must drop Net write, got {effects:?}"
);
assert!(
effects
.iter()
.any(|effect| matches!(effect.kind, EffectKind::Stdio)),
"stdio observe should pass read_only ceiling, got {effects:?}"
);
}
#[test]
fn effect_record_round_trips_through_serde() {
let effects = vec![
EffectRecord::new(EffectKind::Net, EffectScope::Write)
.with_resource("https://api.example/v1"),
EffectRecord::new(EffectKind::Fs, EffectScope::Read).with_resource("/workspace/src"),
EffectRecord::new(
EffectKind::Llm {
provider: Some("anthropic".to_string()),
model: Some("claude-3-7-sonnet".to_string()),
},
EffectScope::Write,
),
EffectRecord::new(
EffectKind::Tool {
name: "search".to_string(),
},
EffectScope::Read,
),
];
let encoded = serde_json::to_string(&effects).expect("encode");
let decoded: Vec<EffectRecord> = serde_json::from_str(&encoded).expect("decode");
assert_eq!(decoded, effects);
}
#[test]
fn empty_source_returns_no_effects() {
let effects = compute_handoff_effects("fn main() {}", None);
assert!(effects.is_empty(), "got {effects:?}");
}
#[test]
fn effects_from_metadata_round_trips_typed_payload() {
let effects = vec![EffectRecord::new(EffectKind::Net, EffectScope::Write)
.with_resource("https://api.example")];
let mut metadata: BTreeMap<String, serde_json::Value> = BTreeMap::new();
metadata.insert(
"effects".to_string(),
serde_json::to_value(&effects).expect("encode"),
);
assert_eq!(effects_from_metadata(&metadata), effects);
}
#[test]
fn deduplicates_repeated_effects() {
let source = r#"fn main() {
http_get("https://example.test")
http_get("https://example.test")
http_get("https://example.test")
}"#;
let effects = compute_handoff_effects(source, None);
let net_count = effects
.iter()
.filter(|effect| matches!(effect.kind, EffectKind::Net))
.count();
assert_eq!(net_count, 1, "expected dedup, got {effects:?}");
}
}