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| !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}