Skip to main content

harn_vm/connectors/
effect_policy.rs

1use 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
80/// Return an actionable lint reason when a typed Harness method exceeds an
81/// export's default ceiling. Both the contract effects and the ceiling are the
82/// same values used by runtime enforcement; the linter does not maintain a
83/// second capability-name table.
84pub 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}