Skip to main content

harn_vm/
autonomy.rs

1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::future::Future;
4use std::pin::Pin;
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value as JsonValue;
8use uuid::Uuid;
9
10use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
11use crate::stdlib::hitl::append_approval_request_on;
12use crate::triggers::dispatcher::current_dispatch_context;
13use crate::trust_graph::{append_trust_record, AutonomyTier, TrustOutcome, TrustRecord};
14use crate::value::{categorized_error, ErrorCategory, VmError, VmValue};
15
16thread_local! {
17    static AUTONOMY_POLICY_STACK: RefCell<Vec<AutonomyPolicy>> = const { RefCell::new(Vec::new()) };
18}
19
20#[derive(Clone, Debug, Default, Deserialize, Serialize)]
21#[serde(default)]
22pub struct AutonomyPolicy {
23    pub agent_id: Option<String>,
24    pub autonomy_tier: Option<AutonomyTier>,
25    pub tier: Option<AutonomyTier>,
26    pub action_tiers: BTreeMap<String, AutonomyTier>,
27    pub agent_tiers: BTreeMap<String, AutonomyTier>,
28    pub agent_action_tiers: BTreeMap<String, BTreeMap<String, AutonomyTier>>,
29    pub reviewers: Vec<String>,
30}
31
32impl AutonomyPolicy {
33    fn effective_tier_for(
34        &self,
35        agent_id: &str,
36        action: &SideEffectAction,
37    ) -> Option<AutonomyTier> {
38        self.agent_action_tiers
39            .get(agent_id)
40            .and_then(|tiers| {
41                tiers
42                    .get(action.builtin)
43                    .or_else(|| tiers.get(action.class))
44                    .copied()
45            })
46            .or_else(|| self.agent_tiers.get(agent_id).copied())
47            .or_else(|| {
48                self.action_tiers
49                    .get(action.builtin)
50                    .or_else(|| self.action_tiers.get(action.class))
51                    .copied()
52            })
53            .or(self.autonomy_tier)
54            .or(self.tier)
55    }
56}
57
58fn action(
59    builtin: &'static str,
60    class: &'static str,
61    capability: &'static str,
62) -> SideEffectAction {
63    SideEffectAction {
64        builtin,
65        class,
66        capability,
67    }
68}
69
70fn workspace_write_action(builtin: &'static str, class: &'static str) -> SideEffectAction {
71    action(builtin, class, "workspace.write_text")
72}
73
74fn first_matching_action(
75    name: &str,
76    builtins: &[&'static str],
77    class: &'static str,
78    capability: &'static str,
79) -> Option<SideEffectAction> {
80    builtins
81        .iter()
82        .find(|builtin| **builtin == name)
83        .map(|builtin| action(builtin, class, capability))
84}
85
86fn first_workspace_write_action(
87    name: &str,
88    builtins: &[&'static str],
89    class: &'static str,
90) -> Option<SideEffectAction> {
91    builtins
92        .iter()
93        .find(|builtin| **builtin == name)
94        .map(|builtin| workspace_write_action(builtin, class))
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub struct SideEffectAction {
99    pub builtin: &'static str,
100    pub class: &'static str,
101    pub capability: &'static str,
102}
103
104#[derive(Clone, Debug)]
105struct AutonomyIdentity {
106    agent_id: String,
107    trace_id: String,
108    tier: AutonomyTier,
109    reviewers: Vec<String>,
110}
111
112#[derive(Clone, Debug)]
113pub enum AutonomyDecision {
114    Skip(VmValue),
115    AllowApproved,
116}
117
118pub struct AutonomyPolicyGuard;
119
120impl Drop for AutonomyPolicyGuard {
121    fn drop(&mut self) {
122        AUTONOMY_POLICY_STACK.with(|stack| {
123            stack.borrow_mut().pop();
124        });
125    }
126}
127
128pub fn push_autonomy_policy(policy: AutonomyPolicy) -> AutonomyPolicyGuard {
129    AUTONOMY_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
130    AutonomyPolicyGuard
131}
132
133pub fn current_autonomy_policy() -> Option<AutonomyPolicy> {
134    AUTONOMY_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
135}
136
137pub fn is_side_effecting_builtin(name: &str) -> bool {
138    side_effect_action_for_builtin(name).is_some()
139}
140
141pub fn needs_async_side_effect_enforcement(name: &str) -> bool {
142    let Some(action) = side_effect_action_for_builtin(name) else {
143        return false;
144    };
145    current_identity(&action).is_some_and(|identity| identity.tier != AutonomyTier::ActAuto)
146}
147
148pub fn enforce_builtin_side_effect_boxed<'a>(
149    name: &'a str,
150    args: &'a [VmValue],
151) -> Pin<Box<dyn Future<Output = Result<Option<AutonomyDecision>, VmError>> + 'a>> {
152    Box::pin(enforce_builtin_side_effect(name, args))
153}
154
155pub fn side_effect_action_for_builtin(name: &str) -> Option<SideEffectAction> {
156    first_workspace_write_action(
157        name,
158        &["write_file", "write_file_bytes", "append_file"],
159        "fs.write",
160    )
161    .or_else(|| first_workspace_write_action(name, &["mkdir"], "fs.mkdir"))
162    .or_else(|| first_workspace_write_action(name, &["copy_file"], "fs.copy"))
163    .or_else(|| first_matching_action(name, &["delete_file"], "fs.delete", "workspace.delete"))
164    .or_else(|| first_workspace_write_action(name, &["move_file"], "fs.move"))
165    .or_else(|| {
166        first_matching_action(
167            name,
168            &["exec", "exec_at", "shell", "shell_at"],
169            "process.exec",
170            "process.exec",
171        )
172    })
173    .or_else(|| first_matching_action(name, &["host_call"], "host.call", "host.call"))
174    .or_else(|| {
175        first_matching_action(
176            name,
177            &["store_set", "store_delete", "store_save", "store_clear"],
178            "store.write",
179            "store.write",
180        )
181    })
182    .or_else(|| {
183        first_matching_action(
184            name,
185            &[
186                "metadata_set",
187                "metadata_save",
188                "metadata_refresh_hashes",
189                "invalidate_facts",
190            ],
191            "metadata.write",
192            "metadata.write",
193        )
194    })
195    .or_else(|| {
196        first_matching_action(
197            name,
198            &["checkpoint", "checkpoint_delete", "checkpoint_clear"],
199            "checkpoint.write",
200            "checkpoint.write",
201        )
202    })
203    .or_else(|| {
204        first_matching_action(
205            name,
206            &[
207                "sse_server_response",
208                "sse_server_send",
209                "sse_server_heartbeat",
210                "sse_server_flush",
211                "sse_server_close",
212                "sse_server_cancel",
213                "sse_server_mock_receive",
214                "sse_server_mock_disconnect",
215            ],
216            "network.sse.write",
217            "network.sse",
218        )
219    })
220    .or_else(|| {
221        first_matching_action(
222            name,
223            &[
224                "__agent_state_write",
225                "__agent_state_delete",
226                "__agent_state_handoff",
227            ],
228            "agent_state.write",
229            "agent_state.write",
230        )
231    })
232    .or_else(|| first_matching_action(name, &["mcp_release"], "mcp.release", "mcp.release"))
233    .or_else(|| {
234        first_matching_action(
235            name,
236            &[
237                "git.worktree.create",
238                "git.worktree.remove",
239                "git.fetch",
240                "git.rebase",
241                "git.push",
242            ],
243            "git.write",
244            "git.write",
245        )
246    })
247}
248
249pub async fn enforce_builtin_side_effect(
250    name: &str,
251    args: &[VmValue],
252) -> Result<Option<AutonomyDecision>, VmError> {
253    let Some(action) = side_effect_action_for_builtin(name) else {
254        return Ok(None);
255    };
256    let Some(identity) = current_identity(&action) else {
257        return Ok(None);
258    };
259    match identity.tier {
260        AutonomyTier::ActAuto => Ok(None),
261        AutonomyTier::Shadow => {
262            emit_proposal_event(identity.tier, action, args).await?;
263            append_enforcement_record(&identity, action, args, TrustOutcome::Denied, None).await?;
264            Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
265        }
266        AutonomyTier::Suggest => {
267            emit_proposal_event(identity.tier, action, args).await?;
268            let request_id = append_nonblocking_approval_request(&identity, action, args).await?;
269            append_enforcement_record(
270                &identity,
271                action,
272                args,
273                TrustOutcome::Denied,
274                Some(request_id),
275            )
276            .await?;
277            Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
278        }
279        AutonomyTier::ActWithApproval => {
280            let approval = request_approval_before_effect(&identity, action, args).await?;
281            append_enforcement_record(
282                &identity,
283                action,
284                args,
285                TrustOutcome::Success,
286                approval.request_id,
287            )
288            .await?;
289            Ok(Some(AutonomyDecision::AllowApproved))
290        }
291    }
292}
293
294fn current_identity(action: &SideEffectAction) -> Option<AutonomyIdentity> {
295    let scoped = current_autonomy_policy();
296    let dispatch = current_dispatch_context();
297    let agent_id = scoped
298        .as_ref()
299        .and_then(|policy| policy.agent_id.clone())
300        .or_else(|| dispatch.as_ref().map(|context| context.agent_id.clone()))
301        .unwrap_or_else(|| "runtime".to_string());
302    let tier = scoped
303        .as_ref()
304        .and_then(|policy| policy.effective_tier_for(&agent_id, action))
305        .or_else(|| dispatch.as_ref().map(|context| context.autonomy_tier))?;
306    let trace_id = dispatch
307        .as_ref()
308        .map(|context| context.trigger_event.trace_id.0.clone())
309        .unwrap_or_else(|| format!("trace-{}", Uuid::now_v7()));
310    let reviewers = scoped
311        .as_ref()
312        .map(|policy| policy.reviewers.clone())
313        .filter(|reviewers| !reviewers.is_empty())
314        .unwrap_or_default();
315    Some(AutonomyIdentity {
316        agent_id,
317        trace_id,
318        tier,
319        reviewers,
320    })
321}
322
323fn detail_for(action: SideEffectAction, args: &[VmValue]) -> JsonValue {
324    serde_json::json!({
325        "builtin": action.builtin,
326        "action_class": action.class,
327        "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
328    })
329}
330
331async fn emit_proposal_event(
332    tier: AutonomyTier,
333    action: SideEffectAction,
334    args: &[VmValue],
335) -> Result<(), VmError> {
336    let Some(context) = current_dispatch_context() else {
337        return Ok(());
338    };
339    let Some(log) = active_event_log() else {
340        return Ok(());
341    };
342    let topic = Topic::new(crate::TRIGGER_OUTBOX_TOPIC)
343        .map_err(|error| VmError::Runtime(format!("autonomy proposal topic error: {error}")))?;
344    let mut headers = BTreeMap::new();
345    headers.insert(
346        "trace_id".to_string(),
347        context.trigger_event.trace_id.0.clone(),
348    );
349    headers.insert("agent".to_string(), context.agent_id.clone());
350    headers.insert("autonomy_tier".to_string(), tier.as_str().to_string());
351    let payload = serde_json::json!({
352        "agent": context.agent_id,
353        "action": context.action,
354        "builtin": action.builtin,
355        "action_class": action.class,
356        "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
357        "trace_id": context.trigger_event.trace_id.0,
358        "replay_of_event_id": context.replay_of_event_id,
359        "autonomy_tier": tier,
360        "proposal": true,
361    });
362    log.append(
363        &topic,
364        LogEvent::new("dispatch_proposed", payload).with_headers(headers),
365    )
366    .await
367    .map(|_| ())
368    .map_err(|error| VmError::Runtime(format!("failed to append autonomy proposal: {error}")))
369}
370
371async fn append_nonblocking_approval_request(
372    identity: &AutonomyIdentity,
373    action: SideEffectAction,
374    args: &[VmValue],
375) -> Result<String, VmError> {
376    let log = active_event_log().ok_or_else(|| {
377        categorized_error(
378            "autonomy approval requires an active event log",
379            ErrorCategory::ToolRejected,
380        )
381    })?;
382    append_approval_request_on(
383        &log,
384        identity.agent_id.clone(),
385        identity.trace_id.clone(),
386        action.class.to_string(),
387        detail_for(action, args),
388        identity.reviewers.clone(),
389    )
390    .await
391}
392
393struct ApprovalOutcome {
394    request_id: Option<String>,
395}
396
397async fn request_approval_before_effect(
398    identity: &AutonomyIdentity,
399    action: SideEffectAction,
400    args: &[VmValue],
401) -> Result<ApprovalOutcome, VmError> {
402    active_event_log().ok_or_else(|| {
403        categorized_error(
404            "act_with_approval requires an active event log",
405            ErrorCategory::ToolRejected,
406        )
407    })?;
408    let detail = detail_for(action, args);
409    let approval = crate::stdlib::hitl::request_approval_for_side_effect(
410        action.class,
411        detail,
412        identity.agent_id.clone(),
413        identity.reviewers.clone(),
414        vec![action.capability.to_string()],
415    )
416    .await?;
417    let request_id = approval
418        .as_dict()
419        .and_then(|dict| dict.get("request_id"))
420        .map(VmValue::display);
421    Ok(ApprovalOutcome { request_id })
422}
423
424async fn append_enforcement_record(
425    identity: &AutonomyIdentity,
426    action: SideEffectAction,
427    args: &[VmValue],
428    outcome: TrustOutcome,
429    request_id: Option<String>,
430) -> Result<(), VmError> {
431    let Some(log) = active_event_log() else {
432        return Ok(());
433    };
434    let mut record = TrustRecord::new(
435        identity.agent_id.clone(),
436        action.class.to_string(),
437        None,
438        outcome,
439        identity.trace_id.clone(),
440        identity.tier,
441    );
442    record.metadata.insert(
443        "autonomy.enforcement".to_string(),
444        serde_json::json!(match identity.tier {
445            AutonomyTier::Shadow => "shadow_noop",
446            AutonomyTier::Suggest => "suggest_approval_request",
447            AutonomyTier::ActWithApproval => "approval_granted",
448            AutonomyTier::ActAuto => "auto",
449        }),
450    );
451    record
452        .metadata
453        .insert("builtin".to_string(), serde_json::json!(action.builtin));
454    record
455        .metadata
456        .insert("action_class".to_string(), serde_json::json!(action.class));
457    record.metadata.insert(
458        "args".to_string(),
459        serde_json::json!(args
460            .iter()
461            .map(crate::llm::vm_value_to_json)
462            .collect::<Vec<_>>()),
463    );
464    if let Some(request_id) = request_id {
465        record.metadata.insert(
466            "approval_request_id".to_string(),
467            serde_json::json!(request_id),
468        );
469    }
470    append_trust_record(&log, &record)
471        .await
472        .map(|_| ())
473        .map_err(|error| VmError::Runtime(format!("autonomy trust graph append: {error}")))
474}