Skip to main content

harn_vm/
autonomy.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
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
16/// Stable diagnostic prefix for a deny driven by the `needs-human` autonomy
17/// class. Approval surfaces (Slack, IDE, portal) match on this code to render
18/// the deny distinctly from a normal tier-based block.
19pub const HARN_AUT_NEEDS_HUMAN_CODE: &str = "HARN-AUT-NEEDS-HUMAN";
20
21/// Canonical string value of the `needs-human` autonomy class. Mirrors
22/// `RepairSafety::NeedsHuman.as_str()` from `harn-parser` so the autonomy
23/// surface and the repair-safety surface stay in lockstep.
24pub const NEEDS_HUMAN_AUTONOMY_CLASS: &str = "needs-human";
25
26thread_local! {
27    static AUTONOMY_POLICY_STACK: RefCell<Vec<AutonomyPolicy>> = const { RefCell::new(Vec::new()) };
28}
29
30#[derive(Clone, Debug, Default, Deserialize, Serialize)]
31#[serde(default)]
32pub struct AutonomyPolicy {
33    pub agent_id: Option<String>,
34    pub autonomy_tier: Option<AutonomyTier>,
35    pub tier: Option<AutonomyTier>,
36    pub action_tiers: BTreeMap<String, AutonomyTier>,
37    pub agent_tiers: BTreeMap<String, AutonomyTier>,
38    pub agent_action_tiers: BTreeMap<String, BTreeMap<String, AutonomyTier>>,
39    pub reviewers: Vec<String>,
40    /// Mark the whole policy as `needs-human`: every side-effecting builtin
41    /// covered by this policy raises a structured `HARN-AUT-NEEDS-HUMAN`
42    /// deny, regardless of the resolved `autonomy_tier`.
43    #[serde(default)]
44    pub requires_human: bool,
45    /// Per-action or per-class needs-human tags. Entries match either the
46    /// builtin name (`write_file`) or the action class (`fs.write`).
47    /// Mutually exclusive with auto-apply: an entry here always wins over
48    /// any tier resolution.
49    #[serde(default, alias = "action_requires_human")]
50    pub requires_human_actions: BTreeSet<String>,
51    /// Per-agent needs-human tags. If an agent is listed here, every side
52    /// effect it attempts is treated as needs-human.
53    #[serde(default)]
54    pub requires_human_agents: BTreeSet<String>,
55}
56
57impl AutonomyPolicy {
58    fn effective_tier_for(
59        &self,
60        agent_id: &str,
61        action: &SideEffectAction,
62    ) -> Option<AutonomyTier> {
63        self.agent_action_tiers
64            .get(agent_id)
65            .and_then(|tiers| {
66                tiers
67                    .get(action.builtin)
68                    .or_else(|| tiers.get(action.class))
69                    .copied()
70            })
71            .or_else(|| self.agent_tiers.get(agent_id).copied())
72            .or_else(|| {
73                self.action_tiers
74                    .get(action.builtin)
75                    .or_else(|| self.action_tiers.get(action.class))
76                    .copied()
77            })
78            .or(self.autonomy_tier)
79            .or(self.tier)
80    }
81
82    /// Resolve whether a given (agent, action) is tagged `needs-human` under
83    /// this policy. Any positive signal — blanket `requires_human`, a
84    /// per-agent tag, or a per-builtin/per-class tag — flips the action into
85    /// the needs-human discipline.
86    fn is_needs_human(&self, agent_id: &str, action: &SideEffectAction) -> bool {
87        if self.requires_human {
88            return true;
89        }
90        if self.requires_human_agents.contains(agent_id) {
91            return true;
92        }
93        if self.requires_human_actions.contains(action.builtin)
94            || self.requires_human_actions.contains(action.class)
95        {
96            return true;
97        }
98        false
99    }
100}
101
102fn action(
103    builtin: &'static str,
104    class: &'static str,
105    capability: &'static str,
106) -> SideEffectAction {
107    SideEffectAction {
108        builtin,
109        class,
110        capability,
111    }
112}
113
114fn workspace_write_action(builtin: &'static str, class: &'static str) -> SideEffectAction {
115    action(builtin, class, "workspace.write_text")
116}
117
118fn first_matching_action(
119    name: &str,
120    builtins: &[&'static str],
121    class: &'static str,
122    capability: &'static str,
123) -> Option<SideEffectAction> {
124    builtins
125        .iter()
126        .find(|builtin| **builtin == name)
127        .map(|builtin| action(builtin, class, capability))
128}
129
130fn first_workspace_write_action(
131    name: &str,
132    builtins: &[&'static str],
133    class: &'static str,
134) -> Option<SideEffectAction> {
135    builtins
136        .iter()
137        .find(|builtin| **builtin == name)
138        .map(|builtin| workspace_write_action(builtin, class))
139}
140
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub struct SideEffectAction {
143    pub builtin: &'static str,
144    pub class: &'static str,
145    pub capability: &'static str,
146}
147
148#[derive(Clone, Debug)]
149struct AutonomyIdentity {
150    agent_id: String,
151    trace_id: String,
152    tier: AutonomyTier,
153    reviewers: Vec<String>,
154    /// Whether this (agent, action) is tagged `needs-human` by the
155    /// currently-active autonomy policy. When true the dispatcher MUST
156    /// deny auto-apply regardless of `tier`.
157    requires_human: bool,
158}
159
160#[derive(Clone, Debug)]
161pub enum AutonomyDecision {
162    Skip(VmValue),
163    AllowApproved,
164}
165
166pub struct AutonomyPolicyGuard;
167
168impl Drop for AutonomyPolicyGuard {
169    fn drop(&mut self) {
170        AUTONOMY_POLICY_STACK.with(|stack| {
171            stack.borrow_mut().pop();
172        });
173    }
174}
175
176pub fn push_autonomy_policy(policy: AutonomyPolicy) -> AutonomyPolicyGuard {
177    AUTONOMY_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
178    AutonomyPolicyGuard
179}
180
181pub fn current_autonomy_policy() -> Option<AutonomyPolicy> {
182    AUTONOMY_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
183}
184
185/// Per-task ambient-scope swap of the autonomy-policy stack. See
186/// `orchestration::ambient_scope`: a worker inherits its parent's autonomy
187/// tier, and `push_autonomy_policy` guards are held across `.await`, so the
188/// stack must follow the task rather than leak to interleaved siblings.
189pub(crate) fn swap_autonomy_policy_stack(next: Vec<AutonomyPolicy>) -> Vec<AutonomyPolicy> {
190    AUTONOMY_POLICY_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
191}
192
193pub fn is_side_effecting_builtin(name: &str) -> bool {
194    side_effect_action_for_builtin(name).is_some()
195}
196
197pub fn needs_async_side_effect_enforcement(name: &str) -> bool {
198    let Some(action) = side_effect_action_for_builtin(name) else {
199        return false;
200    };
201    current_identity(&action)
202        // `needs-human` always needs the async enforcement path so the
203        // dispatcher can emit the structured deny + approval-request,
204        // even when the resolved tier is `ActAuto`.
205        .is_some_and(|identity| identity.requires_human || identity.tier != AutonomyTier::ActAuto)
206}
207
208pub fn enforce_builtin_side_effect_boxed<'a>(
209    name: &'a str,
210    args: &'a [VmValue],
211) -> Pin<Box<dyn Future<Output = Result<Option<AutonomyDecision>, VmError>> + Send + 'a>> {
212    Box::pin(enforce_builtin_side_effect(name, args))
213}
214
215pub fn side_effect_action_for_builtin(name: &str) -> Option<SideEffectAction> {
216    first_workspace_write_action(
217        name,
218        &[
219            "write_file",
220            "write_file_bytes",
221            "append_file",
222            "append_file_locked",
223        ],
224        "fs.write",
225    )
226    .or_else(|| first_workspace_write_action(name, &["mkdir"], "fs.mkdir"))
227    .or_else(|| first_workspace_write_action(name, &["mkdtemp"], "fs.mkdtemp"))
228    .or_else(|| {
229        first_workspace_write_action(name, &["mkdtemp_in_workspace"], "fs.mkdtemp_in_workspace")
230    })
231    .or_else(|| first_workspace_write_action(name, &["copy_file"], "fs.copy"))
232    .or_else(|| first_matching_action(name, &["delete_file"], "fs.delete", "workspace.delete"))
233    .or_else(|| first_workspace_write_action(name, &["move_file"], "fs.move"))
234    .or_else(|| {
235        first_matching_action(
236            name,
237            &["exec", "exec_at", "shell", "shell_at"],
238            "process.exec",
239            "process.exec",
240        )
241    })
242    .or_else(|| first_matching_action(name, &["host_call"], "host.call", "host.call"))
243    .or_else(|| {
244        first_matching_action(
245            name,
246            &["store_set", "store_delete", "store_save", "store_clear"],
247            "store.write",
248            "store.write",
249        )
250    })
251    .or_else(|| {
252        first_matching_action(
253            name,
254            &[
255                "metadata_set",
256                "metadata_save",
257                "metadata_refresh_hashes",
258                "invalidate_facts",
259                "path_metadata_set",
260                "verification_profiles_set",
261                "verification_profile_record_run",
262            ],
263            "metadata.write",
264            "metadata.write",
265        )
266    })
267    .or_else(|| {
268        first_matching_action(
269            name,
270            &["checkpoint", "checkpoint_delete", "checkpoint_clear"],
271            "checkpoint.write",
272            "checkpoint.write",
273        )
274    })
275    .or_else(|| {
276        first_matching_action(
277            name,
278            &[
279                "sse_server_response",
280                "sse_server_send",
281                "sse_server_heartbeat",
282                "sse_server_flush",
283                "sse_server_close",
284                "sse_server_cancel",
285                "sse_server_mock_receive",
286                "sse_server_mock_disconnect",
287            ],
288            "network.sse.write",
289            "network.sse",
290        )
291    })
292    .or_else(|| {
293        first_matching_action(
294            name,
295            &[
296                "__agent_state_write",
297                "__agent_state_delete",
298                "__agent_state_handoff",
299            ],
300            "agent_state.write",
301            "agent_state.write",
302        )
303    })
304    .or_else(|| first_matching_action(name, &["mcp_release"], "mcp.release", "mcp.release"))
305    .or_else(|| {
306        first_matching_action(
307            name,
308            &[
309                "git.worktree.create",
310                "git.worktree.remove",
311                "git.fetch",
312                "git.rebase",
313                "git.push",
314            ],
315            "git.write",
316            "git.write",
317        )
318    })
319}
320
321pub async fn enforce_builtin_side_effect(
322    name: &str,
323    args: &[VmValue],
324) -> Result<Option<AutonomyDecision>, VmError> {
325    let Some(action) = side_effect_action_for_builtin(name) else {
326        return Ok(None);
327    };
328    let Some(identity) = current_identity(&action) else {
329        return Ok(None);
330    };
331    // `needs-human` is a transverse discipline: it forbids auto-apply even
332    // when the resolved tier is `ActAuto`. Check this *before* tier
333    // dispatch so no tier can ever override it.
334    if identity.requires_human {
335        emit_proposal_event(identity.tier, action, args).await?;
336        let request_id = append_needs_human_approval_request(&identity, action, args).await?;
337        append_enforcement_record(
338            &identity,
339            action,
340            args,
341            TrustOutcome::Denied,
342            Some(request_id.clone()),
343        )
344        .await?;
345        return Err(needs_human_deny_error(&identity, action, &request_id));
346    }
347    match identity.tier {
348        AutonomyTier::ActAuto => Ok(None),
349        AutonomyTier::Shadow => {
350            emit_proposal_event(identity.tier, action, args).await?;
351            append_enforcement_record(&identity, action, args, TrustOutcome::Denied, None).await?;
352            Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
353        }
354        AutonomyTier::Suggest => {
355            emit_proposal_event(identity.tier, action, args).await?;
356            let request_id = append_nonblocking_approval_request(&identity, action, args).await?;
357            append_enforcement_record(
358                &identity,
359                action,
360                args,
361                TrustOutcome::Denied,
362                Some(request_id),
363            )
364            .await?;
365            Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
366        }
367        AutonomyTier::ActWithApproval => {
368            let approval = request_approval_before_effect(&identity, action, args).await?;
369            append_enforcement_record(
370                &identity,
371                action,
372                args,
373                TrustOutcome::Success,
374                approval.request_id,
375            )
376            .await?;
377            Ok(Some(AutonomyDecision::AllowApproved))
378        }
379    }
380}
381
382fn current_identity(action: &SideEffectAction) -> Option<AutonomyIdentity> {
383    let scoped = current_autonomy_policy();
384    let dispatch = current_dispatch_context();
385    let agent_id = scoped
386        .as_ref()
387        .and_then(|policy| policy.agent_id.clone())
388        .or_else(|| dispatch.as_ref().map(|context| context.agent_id.clone()))
389        .unwrap_or_else(|| "runtime".to_string());
390    let tier = scoped
391        .as_ref()
392        .and_then(|policy| policy.effective_tier_for(&agent_id, action))
393        .or_else(|| dispatch.as_ref().map(|context| context.autonomy_tier))?;
394    let trace_id = dispatch
395        .as_ref()
396        .map(|context| context.trigger_event.trace_id.0.clone())
397        .unwrap_or_else(|| format!("trace-{}", Uuid::now_v7()));
398    let reviewers = scoped
399        .as_ref()
400        .map(|policy| policy.reviewers.clone())
401        .filter(|reviewers| !reviewers.is_empty())
402        .unwrap_or_default();
403    let requires_human = scoped
404        .as_ref()
405        .map(|policy| policy.is_needs_human(&agent_id, action))
406        .unwrap_or(false);
407    Some(AutonomyIdentity {
408        agent_id,
409        trace_id,
410        tier,
411        reviewers,
412        requires_human,
413    })
414}
415
416fn detail_for(action: SideEffectAction, args: &[VmValue]) -> JsonValue {
417    serde_json::json!({
418        "builtin": action.builtin,
419        "action_class": action.class,
420        "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
421    })
422}
423
424fn needs_human_detail(action: SideEffectAction, args: &[VmValue]) -> JsonValue {
425    let mut detail = detail_for(action, args);
426    if let Some(obj) = detail.as_object_mut() {
427        obj.insert(
428            "autonomy_class".to_string(),
429            JsonValue::String(NEEDS_HUMAN_AUTONOMY_CLASS.to_string()),
430        );
431        obj.insert("requires_human".to_string(), JsonValue::Bool(true));
432        obj.insert(
433            "deny_code".to_string(),
434            JsonValue::String(HARN_AUT_NEEDS_HUMAN_CODE.to_string()),
435        );
436    }
437    detail
438}
439
440async fn emit_proposal_event(
441    tier: AutonomyTier,
442    action: SideEffectAction,
443    args: &[VmValue],
444) -> Result<(), VmError> {
445    let Some(context) = current_dispatch_context() else {
446        return Ok(());
447    };
448    let Some(log) = active_event_log() else {
449        return Ok(());
450    };
451    let topic = Topic::new(crate::TRIGGER_OUTBOX_TOPIC)
452        .map_err(|error| VmError::Runtime(format!("autonomy proposal topic error: {error}")))?;
453    let mut headers = BTreeMap::new();
454    headers.insert(
455        "trace_id".to_string(),
456        context.trigger_event.trace_id.0.clone(),
457    );
458    headers.insert("agent".to_string(), context.agent_id.clone());
459    headers.insert("autonomy_tier".to_string(), tier.as_str().to_string());
460    let payload = serde_json::json!({
461        "agent": context.agent_id,
462        "action": context.action,
463        "builtin": action.builtin,
464        "action_class": action.class,
465        "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
466        "trace_id": context.trigger_event.trace_id.0,
467        "replay_of_event_id": context.replay_of_event_id,
468        "autonomy_tier": tier,
469        "proposal": true,
470    });
471    log.append(
472        &topic,
473        LogEvent::new("dispatch_proposed", payload).with_headers(headers),
474    )
475    .await
476    .map(|_| ())
477    .map_err(|error| VmError::Runtime(format!("failed to append autonomy proposal: {error}")))
478}
479
480async fn append_nonblocking_approval_request(
481    identity: &AutonomyIdentity,
482    action: SideEffectAction,
483    args: &[VmValue],
484) -> Result<String, VmError> {
485    let log = active_event_log().ok_or_else(|| {
486        categorized_error(
487            "autonomy approval requires an active event log",
488            ErrorCategory::ToolRejected,
489        )
490    })?;
491    append_approval_request_on(
492        &log,
493        identity.agent_id.clone(),
494        identity.trace_id.clone(),
495        action.class.to_string(),
496        detail_for(action, args),
497        identity.reviewers.clone(),
498    )
499    .await
500}
501
502/// Emit a non-blocking approval request tagged with the `needs-human`
503/// autonomy class. Surfaces (Slack-approval, IDE, portal) match on the
504/// `autonomy_class` field in the request payload's `detail` to render the
505/// pending row distinctly from a normal tier-driven approval ask.
506async fn append_needs_human_approval_request(
507    identity: &AutonomyIdentity,
508    action: SideEffectAction,
509    args: &[VmValue],
510) -> Result<String, VmError> {
511    let log = active_event_log().ok_or_else(|| {
512        categorized_error(
513            "needs-human autonomy class requires an active event log",
514            ErrorCategory::ToolRejected,
515        )
516    })?;
517    append_approval_request_on(
518        &log,
519        identity.agent_id.clone(),
520        identity.trace_id.clone(),
521        format!("{}#needs-human", action.class),
522        needs_human_detail(action, args),
523        identity.reviewers.clone(),
524    )
525    .await
526}
527
528/// Build the structured deny returned when a `needs-human`-tagged side
529/// effect is attempted. The message is prefixed with [`HARN_AUT_NEEDS_HUMAN_CODE`]
530/// so approval surfaces and structured-error consumers can match on a stable
531/// token rather than substring-matching the human-readable text.
532fn needs_human_deny_error(
533    identity: &AutonomyIdentity,
534    action: SideEffectAction,
535    request_id: &str,
536) -> VmError {
537    categorized_error(
538        format!(
539            "{code}: side effect `{builtin}` ({class}) is tagged `needs-human` for agent `{agent}`; \
540             auto-apply is forbidden regardless of autonomy tier `{tier}`. \
541             Approval request `{request_id}` was queued.",
542            code = HARN_AUT_NEEDS_HUMAN_CODE,
543            builtin = action.builtin,
544            class = action.class,
545            agent = identity.agent_id,
546            tier = identity.tier.as_str(),
547            request_id = request_id,
548        ),
549        ErrorCategory::ToolRejected,
550    )
551}
552
553struct ApprovalOutcome {
554    request_id: Option<String>,
555}
556
557async fn request_approval_before_effect(
558    identity: &AutonomyIdentity,
559    action: SideEffectAction,
560    args: &[VmValue],
561) -> Result<ApprovalOutcome, VmError> {
562    active_event_log().ok_or_else(|| {
563        categorized_error(
564            "act_with_approval requires an active event log",
565            ErrorCategory::ToolRejected,
566        )
567    })?;
568    let detail = detail_for(action, args);
569    let approval = crate::stdlib::hitl::request_approval_for_side_effect(
570        action.class,
571        detail,
572        identity.agent_id.clone(),
573        identity.reviewers.clone(),
574        vec![action.capability.to_string()],
575    )
576    .await?;
577    let request_id = approval
578        .as_dict()
579        .and_then(|dict| dict.get("request_id"))
580        .map(VmValue::display);
581    Ok(ApprovalOutcome { request_id })
582}
583
584async fn append_enforcement_record(
585    identity: &AutonomyIdentity,
586    action: SideEffectAction,
587    args: &[VmValue],
588    outcome: TrustOutcome,
589    request_id: Option<String>,
590) -> Result<(), VmError> {
591    let Some(log) = active_event_log() else {
592        return Ok(());
593    };
594    let mut record = TrustRecord::new(
595        identity.agent_id.clone(),
596        action.class.to_string(),
597        None,
598        outcome,
599        identity.trace_id.clone(),
600        identity.tier,
601    );
602    let enforcement = if identity.requires_human {
603        // `needs-human` always denies regardless of tier — record the
604        // distinct enforcement label so audit consumers can filter on it
605        // without re-deriving the discipline from policy snapshots.
606        "needs_human_denied"
607    } else {
608        match identity.tier {
609            AutonomyTier::Shadow => "shadow_noop",
610            AutonomyTier::Suggest => "suggest_approval_request",
611            AutonomyTier::ActWithApproval => "approval_granted",
612            AutonomyTier::ActAuto => "auto",
613        }
614    };
615    record.metadata.insert(
616        "autonomy.enforcement".to_string(),
617        serde_json::json!(enforcement),
618    );
619    record
620        .metadata
621        .insert("builtin".to_string(), serde_json::json!(action.builtin));
622    record
623        .metadata
624        .insert("action_class".to_string(), serde_json::json!(action.class));
625    // Every record carries an explicit autonomy class so the trust-graph
626    // record (`TrustRecord.metadata.autonomy_class`) flows downstream into
627    // approval surfaces and receipt envelopes. `needs-human` is mutually
628    // exclusive with the tier-based labels.
629    let autonomy_class = if identity.requires_human {
630        NEEDS_HUMAN_AUTONOMY_CLASS.to_string()
631    } else {
632        identity.tier.as_str().to_string()
633    };
634    record.metadata.insert(
635        "autonomy_class".to_string(),
636        serde_json::json!(autonomy_class),
637    );
638    record.metadata.insert(
639        "requires_human".to_string(),
640        serde_json::json!(identity.requires_human),
641    );
642    if identity.requires_human {
643        record.metadata.insert(
644            "deny_code".to_string(),
645            serde_json::json!(HARN_AUT_NEEDS_HUMAN_CODE),
646        );
647    }
648    record.metadata.insert(
649        "args".to_string(),
650        serde_json::json!(args
651            .iter()
652            .map(crate::llm::vm_value_to_json)
653            .collect::<Vec<_>>()),
654    );
655    if let Some(request_id) = request_id {
656        record.metadata.insert(
657            "approval_request_id".to_string(),
658            serde_json::json!(request_id),
659        );
660    }
661    append_trust_record(&log, &record)
662        .await
663        .map(|_| ())
664        .map_err(|error| VmError::Runtime(format!("autonomy trust graph append: {error}")))
665}