harn_vm/connectors/
effect_policy.rs1use std::collections::BTreeMap;
2
3use serde::Serialize;
4
5use crate::orchestration::CapabilityPolicy;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ConnectorExportEffectClass {
10 HotPathLocal,
11 ConnectorOutbound,
12 Activation,
13}
14
15#[derive(Clone, Debug, Default)]
16pub struct HarnConnectorEffectPolicies {
17 overrides: BTreeMap<String, Option<CapabilityPolicy>>,
18}
19
20impl HarnConnectorEffectPolicies {
21 pub fn set_export_policy(
22 &mut self,
23 export: impl Into<String>,
24 policy: CapabilityPolicy,
25 ) -> &mut Self {
26 self.overrides.insert(export.into(), Some(policy));
27 self
28 }
29
30 pub fn trust_export(&mut self, export: impl Into<String>) -> &mut Self {
31 self.overrides.insert(export.into(), None);
32 self
33 }
34
35 pub fn clear_export_override(&mut self, export: &str) -> &mut Self {
36 self.overrides.remove(export);
37 self
38 }
39
40 pub(crate) fn policy_for_export(&self, export: &str) -> Option<CapabilityPolicy> {
41 self.overrides
42 .get(export)
43 .cloned()
44 .unwrap_or_else(|| default_connector_export_policy(export))
45 }
46}
47
48pub fn connector_export_effect_class(export: &str) -> Option<ConnectorExportEffectClass> {
49 match export {
50 "normalize_inbound" => Some(ConnectorExportEffectClass::HotPathLocal),
51 "poll_tick" | "call" => Some(ConnectorExportEffectClass::ConnectorOutbound),
52 "activate" => Some(ConnectorExportEffectClass::Activation),
53 _ => None,
54 }
55}
56
57pub fn default_connector_export_policy(export: &str) -> Option<CapabilityPolicy> {
58 let class = connector_export_effect_class(export)?;
59 Some(policy_for_effect_class(class))
60}
61
62pub fn connector_export_denied_builtin_reason(export: &str, builtin: &str) -> Option<&'static str> {
63 let class = connector_export_effect_class(export)?;
64 match builtin_effect_group(builtin)? {
65 BuiltinEffectGroup::Workspace => Some("ambient filesystem access is not allowed"),
66 BuiltinEffectGroup::Process => Some("process execution is not allowed"),
67 BuiltinEffectGroup::Llm => Some("LLM calls are not allowed"),
68 BuiltinEffectGroup::Mcp => Some("MCP/process-backed connector access is not allowed"),
69 BuiltinEffectGroup::Host => Some("host calls require an explicit host-owned surface"),
70 BuiltinEffectGroup::Network | BuiltinEffectGroup::ConnectorCall => match class {
71 ConnectorExportEffectClass::HotPathLocal => {
72 Some("outbound network/client calls are not allowed on the ingress hot path")
73 }
74 ConnectorExportEffectClass::ConnectorOutbound
75 | ConnectorExportEffectClass::Activation => None,
76 },
77 }
78}
79
80pub fn connector_export_denied_harness_method_reason(
85 export: &str,
86 capability_field: &str,
87 method: &str,
88) -> Option<String> {
89 let policy = default_connector_export_policy(export)?;
90 let capability = harn_builtin_meta::CapabilityId::from_field_name(capability_field)?;
91 let entry = crate::stdlib::capability_method_manifest_entry(capability, method)?;
92 let denied = crate::orchestration::runtime_effects_from_contract(entry.contract.effects, &[])
93 .into_iter()
94 .find(|effect| !crate::orchestration::effect_allowed_by_ceiling(effect, &policy))?;
95 Some(format!(
96 "{} is outside the `{export}` default effect ceiling",
97 crate::orchestration::effect_record_summary(&denied)
98 ))
99}
100
101fn policy_for_effect_class(class: ConnectorExportEffectClass) -> CapabilityPolicy {
102 let mut capabilities = BTreeMap::from([
103 ("secrets".to_string(), vec!["read".to_string()]),
104 ("observability".to_string(), vec!["emit".to_string()]),
105 ("clock".to_string(), vec!["now".to_string()]),
106 ("state".to_string(), vec!["read".to_string()]),
107 ]);
108 if matches!(
109 class,
110 ConnectorExportEffectClass::ConnectorOutbound | ConnectorExportEffectClass::Activation
111 ) {
112 capabilities.insert("network".to_string(), vec!["http".to_string()]);
113 capabilities
114 .entry("state".to_string())
115 .or_default()
116 .push("write".to_string());
117 }
118
119 CapabilityPolicy {
120 capabilities,
121 side_effect_level: Some(match class {
122 ConnectorExportEffectClass::HotPathLocal => "read_only".to_string(),
123 ConnectorExportEffectClass::ConnectorOutbound
124 | ConnectorExportEffectClass::Activation => "network".to_string(),
125 }),
126 ..CapabilityPolicy::default()
127 }
128}
129
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131enum BuiltinEffectGroup {
132 Workspace,
133 Process,
134 Network,
135 Llm,
136 Mcp,
137 Host,
138 ConnectorCall,
139}
140
141fn builtin_effect_group(builtin: &str) -> Option<BuiltinEffectGroup> {
142 match builtin {
143 "read_file"
144 | "read_file_result"
145 | "read_file_bytes"
146 | "package_snapshot_open"
147 | "render"
148 | "render_prompt"
149 | "render_with_provenance"
150 | "write_file"
151 | "write_file_bytes"
152 | "replace_file"
153 | "replace_file_result"
154 | "replace_file_bytes"
155 | "replace_file_bytes_result"
156 | "append_file"
157 | "append_file_locked"
158 | "copy_file"
159 | "delete_file"
160 | "mkdir"
161 | "list_dir"
162 | "file_exists"
163 | "stat"
164 | "project_fingerprint"
165 | "project_context_profile_native"
166 | "project_scan_native"
167 | "project_scan_tree_native"
168 | "project_walk_tree_native"
169 | "project_catalog_native"
170 | "__agent_state_init"
171 | "__agent_state_resume"
172 | "__agent_state_write"
173 | "__agent_state_read"
174 | "__agent_state_list"
175 | "__agent_state_delete"
176 | "__agent_state_handoff" => Some(BuiltinEffectGroup::Workspace),
177 "exec" | "exec_at" | "shell" | "shell_at" => Some(BuiltinEffectGroup::Process),
178 "http_get"
179 | "http_post"
180 | "http_put"
181 | "http_patch"
182 | "http_delete"
183 | "http_download"
184 | "http_request"
185 | "http_session_request"
186 | "http_stream_open"
187 | "http_stream_read"
188 | "http_stream_close"
189 | "http_stream_info"
190 | "sse_connect"
191 | "sse_receive"
192 | "websocket_accept"
193 | "websocket_connect"
194 | "websocket_route"
195 | "websocket_send"
196 | "websocket_receive"
197 | "websocket_server" => Some(BuiltinEffectGroup::Network),
198 "llm_call" | "llm_call_safe" | "llm_completion" | "llm_stream" | "llm_stream_call"
199 | "llm_healthcheck" | "agent_loop" => Some(BuiltinEffectGroup::Llm),
200 "vision_ocr" => Some(BuiltinEffectGroup::Process),
201 "mcp_connect"
202 | "mcp_ensure_active"
203 | "mcp_call"
204 | "mcp_list_tools"
205 | "mcp_list_resources"
206 | "mcp_list_resource_templates"
207 | "mcp_read_resource"
208 | "mcp_list_prompts"
209 | "mcp_get_prompt"
210 | "mcp_server_info"
211 | "mcp_disconnect" => Some(BuiltinEffectGroup::Mcp),
212 "host_call" | "host_tool_call" | "host_tool_list" => Some(BuiltinEffectGroup::Host),
213 "connector_call" => Some(BuiltinEffectGroup::ConnectorCall),
214 _ => None,
215 }
216}