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| {
95 !crate::orchestration::contract_effect_allowed_by_ceiling(
96 effect,
97 entry.contract,
98 &policy,
99 )
100 })?;
101 Some(format!(
102 "{} is outside the `{export}` default effect ceiling",
103 crate::orchestration::effect_record_summary(&denied)
104 ))
105}
106
107fn policy_for_effect_class(class: ConnectorExportEffectClass) -> CapabilityPolicy {
108 let mut capabilities = BTreeMap::from([
109 ("secrets".to_string(), vec!["read".to_string()]),
110 ("observability".to_string(), vec!["emit".to_string()]),
111 ("clock".to_string(), vec!["now".to_string()]),
112 ("state".to_string(), vec!["read".to_string()]),
113 ]);
114 if matches!(
115 class,
116 ConnectorExportEffectClass::ConnectorOutbound | ConnectorExportEffectClass::Activation
117 ) {
118 capabilities.insert("network".to_string(), vec!["http".to_string()]);
119 capabilities
120 .entry("state".to_string())
121 .or_default()
122 .push("write".to_string());
123 }
124
125 CapabilityPolicy {
126 capabilities,
127 side_effect_level: Some(match class {
128 ConnectorExportEffectClass::HotPathLocal => "read_only".to_string(),
129 ConnectorExportEffectClass::ConnectorOutbound
130 | ConnectorExportEffectClass::Activation => "network".to_string(),
131 }),
132 ..CapabilityPolicy::default()
133 }
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137enum BuiltinEffectGroup {
138 Workspace,
139 Process,
140 Network,
141 Llm,
142 Mcp,
143 Host,
144 ConnectorCall,
145}
146
147fn builtin_effect_group(builtin: &str) -> Option<BuiltinEffectGroup> {
148 match builtin {
149 "read_file"
150 | "read_file_result"
151 | "read_file_bytes"
152 | "package_snapshot_open"
153 | "render"
154 | "render_prompt"
155 | "render_with_provenance"
156 | "write_file"
157 | "write_file_bytes"
158 | "replace_file"
159 | "replace_file_result"
160 | "replace_file_bytes"
161 | "replace_file_bytes_result"
162 | "append_file"
163 | "append_file_locked"
164 | "copy_file"
165 | "delete_file"
166 | "mkdir"
167 | "list_dir"
168 | "file_exists"
169 | "stat"
170 | "project_fingerprint"
171 | "project_context_profile_native"
172 | "project_scan_native"
173 | "project_scan_tree_native"
174 | "project_walk_tree_native"
175 | "project_catalog_native"
176 | "__agent_state_init"
177 | "__agent_state_resume"
178 | "__agent_state_write"
179 | "__agent_state_read"
180 | "__agent_state_list"
181 | "__agent_state_delete"
182 | "__agent_state_handoff" => Some(BuiltinEffectGroup::Workspace),
183 "exec" | "exec_at" | "shell" | "shell_at" => Some(BuiltinEffectGroup::Process),
184 "http_get"
185 | "http_post"
186 | "http_put"
187 | "http_patch"
188 | "http_delete"
189 | "http_download"
190 | "http_request"
191 | "http_session_request"
192 | "http_stream_open"
193 | "http_stream_read"
194 | "http_stream_close"
195 | "http_stream_info"
196 | "sse_connect"
197 | "sse_receive"
198 | "websocket_accept"
199 | "websocket_connect"
200 | "websocket_route"
201 | "websocket_send"
202 | "websocket_receive"
203 | "websocket_server" => Some(BuiltinEffectGroup::Network),
204 "llm_call" | "llm_call_safe" | "llm_completion" | "llm_stream" | "llm_stream_call"
205 | "llm_healthcheck" | "agent_loop" => Some(BuiltinEffectGroup::Llm),
206 "vision_ocr" => Some(BuiltinEffectGroup::Process),
207 "mcp_connect"
208 | "mcp_ensure_active"
209 | "mcp_call"
210 | "mcp_list_tools"
211 | "mcp_list_resources"
212 | "mcp_list_resource_templates"
213 | "mcp_read_resource"
214 | "mcp_list_prompts"
215 | "mcp_get_prompt"
216 | "mcp_server_info"
217 | "mcp_disconnect" => Some(BuiltinEffectGroup::Mcp),
218 "host_call" | "host_tool_call" | "host_tool_list" => Some(BuiltinEffectGroup::Host),
219 "connector_call" => Some(BuiltinEffectGroup::ConnectorCall),
220 _ => None,
221 }
222}