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};
15use harn_builtin_meta::CapabilityId;
16
17pub const HARN_AUT_NEEDS_HUMAN_CODE: &str = "HARN-AUT-NEEDS-HUMAN";
21
22pub const NEEDS_HUMAN_AUTONOMY_CLASS: &str = "needs-human";
26
27thread_local! {
28 static AUTONOMY_POLICY_STACK: RefCell<Vec<AutonomyPolicy>> = const { RefCell::new(Vec::new()) };
29}
30
31#[derive(Clone, Debug, Default, Deserialize, Serialize)]
32#[serde(default)]
33pub struct AutonomyPolicy {
34 pub agent_id: Option<String>,
35 pub autonomy_tier: Option<AutonomyTier>,
36 pub tier: Option<AutonomyTier>,
37 pub action_tiers: BTreeMap<String, AutonomyTier>,
38 pub agent_tiers: BTreeMap<String, AutonomyTier>,
39 pub agent_action_tiers: BTreeMap<String, BTreeMap<String, AutonomyTier>>,
40 pub reviewers: Vec<String>,
41 #[serde(default)]
45 pub requires_human: bool,
46 #[serde(default, alias = "action_requires_human")]
51 pub requires_human_actions: BTreeSet<String>,
52 #[serde(default)]
55 pub requires_human_agents: BTreeSet<String>,
56}
57
58impl AutonomyPolicy {
59 fn effective_tier_for(
60 &self,
61 agent_id: &str,
62 action: &SideEffectAction,
63 ) -> Option<AutonomyTier> {
64 self.agent_action_tiers
65 .get(agent_id)
66 .and_then(|tiers| {
67 tiers
68 .get(action.builtin)
69 .or_else(|| tiers.get(action.class))
70 .copied()
71 })
72 .or_else(|| self.agent_tiers.get(agent_id).copied())
73 .or_else(|| {
74 self.action_tiers
75 .get(action.builtin)
76 .or_else(|| self.action_tiers.get(action.class))
77 .copied()
78 })
79 .or(self.autonomy_tier)
80 .or(self.tier)
81 }
82
83 fn is_needs_human(&self, agent_id: &str, action: &SideEffectAction) -> bool {
88 if self.requires_human {
89 return true;
90 }
91 if self.requires_human_agents.contains(agent_id) {
92 return true;
93 }
94 if self.requires_human_actions.contains(action.builtin)
95 || self.requires_human_actions.contains(action.class)
96 {
97 return true;
98 }
99 false
100 }
101}
102
103fn action(
104 builtin: &'static str,
105 class: &'static str,
106 capability: &'static str,
107) -> SideEffectAction {
108 SideEffectAction {
109 builtin,
110 class,
111 capability,
112 }
113}
114
115fn workspace_write_action(builtin: &'static str, class: &'static str) -> SideEffectAction {
116 action(builtin, class, "workspace.write_text")
117}
118
119fn first_matching_action(
120 name: &str,
121 builtins: &[&'static str],
122 class: &'static str,
123 capability: &'static str,
124) -> Option<SideEffectAction> {
125 builtins
126 .iter()
127 .find(|builtin| **builtin == name)
128 .map(|builtin| action(builtin, class, capability))
129}
130
131fn first_workspace_write_action(
132 name: &str,
133 builtins: &[&'static str],
134 class: &'static str,
135) -> Option<SideEffectAction> {
136 builtins
137 .iter()
138 .find(|builtin| **builtin == name)
139 .map(|builtin| workspace_write_action(builtin, class))
140}
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub struct SideEffectAction {
144 pub builtin: &'static str,
145 pub class: &'static str,
146 pub capability: &'static str,
147}
148
149#[derive(Clone, Debug)]
150struct AutonomyIdentity {
151 agent_id: String,
152 trace_id: String,
153 tier: AutonomyTier,
154 reviewers: Vec<String>,
155 requires_human: bool,
159}
160
161#[derive(Clone, Debug)]
162pub enum AutonomyDecision {
163 Skip(VmValue),
164 AllowApproved,
165}
166
167pub struct AutonomyPolicyGuard;
168
169impl Drop for AutonomyPolicyGuard {
170 fn drop(&mut self) {
171 AUTONOMY_POLICY_STACK.with(|stack| {
172 stack.borrow_mut().pop();
173 });
174 }
175}
176
177pub fn push_autonomy_policy(policy: AutonomyPolicy) -> AutonomyPolicyGuard {
178 AUTONOMY_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
179 AutonomyPolicyGuard
180}
181
182pub fn current_autonomy_policy() -> Option<AutonomyPolicy> {
183 AUTONOMY_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
184}
185
186pub(crate) fn swap_autonomy_policy_stack(next: Vec<AutonomyPolicy>) -> Vec<AutonomyPolicy> {
191 AUTONOMY_POLICY_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
192}
193
194pub fn is_side_effecting_builtin(name: &str) -> bool {
195 side_effect_action_for_builtin(name).is_some()
196}
197
198pub fn needs_async_side_effect_enforcement(name: &str) -> bool {
199 let Some(action) = side_effect_action_for_builtin(name) else {
200 return false;
201 };
202 current_identity(&action)
203 .is_some_and(|identity| identity.requires_human || identity.tier != AutonomyTier::ActAuto)
207}
208
209pub fn enforce_builtin_side_effect_boxed<'a>(
210 name: &'a str,
211 args: &'a [VmValue],
212) -> Pin<Box<dyn Future<Output = Result<Option<AutonomyDecision>, VmError>> + Send + 'a>> {
213 Box::pin(enforce_builtin_side_effect(name, args))
214}
215
216pub fn side_effect_action_for_builtin(name: &str) -> Option<SideEffectAction> {
217 first_workspace_write_action(
218 name,
219 &[
220 "write_file",
221 "write_file_bytes",
222 "replace_file",
223 "replace_file_result",
224 "replace_file_bytes",
225 "replace_file_bytes_result",
226 "append_file",
227 "append_file_locked",
228 ],
229 "fs.write",
230 )
231 .or_else(|| first_workspace_write_action(name, &["mkdir"], "fs.mkdir"))
232 .or_else(|| first_workspace_write_action(name, &["mkdtemp"], "fs.mkdtemp"))
233 .or_else(|| {
234 first_workspace_write_action(name, &["mkdtemp_in_workspace"], "fs.mkdtemp_in_workspace")
235 })
236 .or_else(|| first_workspace_write_action(name, &["copy_file"], "fs.copy"))
237 .or_else(|| first_matching_action(name, &["delete_file"], "fs.delete", "workspace.delete"))
238 .or_else(|| first_workspace_write_action(name, &["move_file"], "fs.move"))
239 .or_else(|| {
240 first_matching_action(
241 name,
242 &["exec", "exec_at", "shell", "shell_at"],
243 "process.exec",
244 "process.exec",
245 )
246 })
247 .or_else(|| first_matching_action(name, &["host_call"], "host.call", "host.call"))
248 .or_else(|| {
249 first_matching_action(
250 name,
251 &["store_set", "store_delete", "store_save", "store_clear"],
252 "store.write",
253 "store.write",
254 )
255 })
256 .or_else(|| {
257 first_matching_action(
258 name,
259 &[
260 "metadata_set",
261 "metadata_save",
262 "metadata_refresh_hashes",
263 "invalidate_facts",
264 "path_metadata_set",
265 "verification_profiles_set",
266 "verification_profile_record_run",
267 ],
268 "metadata.write",
269 "metadata.write",
270 )
271 })
272 .or_else(|| {
273 first_matching_action(
274 name,
275 &["checkpoint", "checkpoint_delete", "checkpoint_clear"],
276 "checkpoint.write",
277 "checkpoint.write",
278 )
279 })
280 .or_else(|| {
281 first_matching_action(
282 name,
283 &[
284 "sse_server_response",
285 "sse_server_send",
286 "sse_server_heartbeat",
287 "sse_server_flush",
288 "sse_server_close",
289 "sse_server_cancel",
290 "sse_server_mock_receive",
291 "sse_server_mock_disconnect",
292 ],
293 "network.sse.write",
294 "network.sse",
295 )
296 })
297 .or_else(|| {
298 first_matching_action(
299 name,
300 &[
301 "__agent_state_write",
302 "__agent_state_delete",
303 "__agent_state_handoff",
304 ],
305 "agent_state.write",
306 "agent_state.write",
307 )
308 })
309 .or_else(|| first_matching_action(name, &["mcp_release"], "mcp.release", "mcp.release"))
310 .or_else(|| {
311 first_matching_action(
312 name,
313 &[
314 "git.worktree.create",
315 "git.worktree.remove",
316 "git.fetch",
317 "git.rebase",
318 "git.push",
319 ],
320 "git.write",
321 "git.write",
322 )
323 })
324}
325
326pub async fn enforce_builtin_side_effect(
327 name: &str,
328 args: &[VmValue],
329) -> Result<Option<AutonomyDecision>, VmError> {
330 let Some(action) = side_effect_action_for_builtin(name) else {
331 return Ok(None);
332 };
333 enforce_side_effect(action, args, None).await
334}
335
336pub(crate) async fn enforce_capability_side_effect(
344 harness: &crate::harness::VmHarness,
345 capability: CapabilityId,
346 method: &str,
347 args: &[VmValue],
348) -> Result<Option<AutonomyDecision>, VmError> {
349 let action = match (capability, method) {
350 (CapabilityId::Fs, "write_text") => Some(workspace_write_action("write_text", "fs.write")),
351 (CapabilityId::Fs, "write_bytes") => {
352 Some(workspace_write_action("write_bytes", "fs.write"))
353 }
354 (CapabilityId::Fs, "append_text") => {
355 Some(workspace_write_action("append_text", "fs.write"))
356 }
357 (CapabilityId::Fs, "mkdir") => Some(workspace_write_action("mkdir", "fs.mkdir")),
358 (CapabilityId::Fs, "mkdtemp") => Some(workspace_write_action("mkdtemp", "fs.mkdtemp")),
359 (CapabilityId::Fs, "copy") => Some(workspace_write_action("copy", "fs.copy")),
360 (CapabilityId::Fs, "move") => Some(workspace_write_action("move", "fs.move")),
361 (CapabilityId::Fs, "delete") => Some(action("delete", "fs.delete", "workspace.delete")),
362 (CapabilityId::Process, "exec")
363 | (CapabilityId::Process, "exec_at")
364 | (CapabilityId::Process, "shell")
365 | (CapabilityId::Process, "shell_at")
366 | (CapabilityId::Process, "run") => Some(action("process", "process.exec", "process.exec")),
367 _ => None,
368 };
369 match action {
370 Some(action) => enforce_side_effect(action, args, Some(harness)).await,
371 None => Ok(None),
372 }
373}
374
375async fn enforce_side_effect(
376 action: SideEffectAction,
377 args: &[VmValue],
378 harness: Option<&crate::harness::VmHarness>,
379) -> Result<Option<AutonomyDecision>, VmError> {
380 let Some(identity) = current_identity(&action) else {
381 return Ok(None);
382 };
383 if identity.requires_human {
387 emit_proposal_event(identity.tier, action, args).await?;
388 let request_id = append_needs_human_approval_request(&identity, action, args).await?;
389 append_enforcement_record(
390 &identity,
391 action,
392 args,
393 TrustOutcome::Denied,
394 Some(request_id.clone()),
395 )
396 .await?;
397 return Err(needs_human_deny_error(&identity, action, &request_id));
398 }
399 match identity.tier {
400 AutonomyTier::ActAuto => Ok(None),
401 AutonomyTier::Shadow => {
402 emit_proposal_event(identity.tier, action, args).await?;
403 append_enforcement_record(&identity, action, args, TrustOutcome::Denied, None).await?;
404 Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
405 }
406 AutonomyTier::Suggest => {
407 emit_proposal_event(identity.tier, action, args).await?;
408 let request_id = append_nonblocking_approval_request(&identity, action, args).await?;
409 append_enforcement_record(
410 &identity,
411 action,
412 args,
413 TrustOutcome::Denied,
414 Some(request_id),
415 )
416 .await?;
417 Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
418 }
419 AutonomyTier::ActWithApproval => {
420 let approval = request_approval_before_effect(&identity, action, args, harness).await?;
421 append_enforcement_record(
422 &identity,
423 action,
424 args,
425 TrustOutcome::Success,
426 approval.request_id,
427 )
428 .await?;
429 Ok(Some(AutonomyDecision::AllowApproved))
430 }
431 }
432}
433
434fn current_identity(action: &SideEffectAction) -> Option<AutonomyIdentity> {
435 let scoped = current_autonomy_policy();
436 let dispatch = current_dispatch_context();
437 let agent_id = scoped
438 .as_ref()
439 .and_then(|policy| policy.agent_id.clone())
440 .or_else(|| dispatch.as_ref().map(|context| context.agent_id.clone()))
441 .unwrap_or_else(|| "runtime".to_string());
442 let tier = scoped
443 .as_ref()
444 .and_then(|policy| policy.effective_tier_for(&agent_id, action))
445 .or_else(|| dispatch.as_ref().map(|context| context.autonomy_tier))?;
446 let trace_id = dispatch
447 .as_ref()
448 .map(|context| context.trigger_event.trace_id.0.clone())
449 .unwrap_or_else(|| format!("trace-{}", Uuid::now_v7()));
450 let reviewers = scoped
451 .as_ref()
452 .map(|policy| policy.reviewers.clone())
453 .filter(|reviewers| !reviewers.is_empty())
454 .unwrap_or_default();
455 let requires_human = scoped
456 .as_ref()
457 .map(|policy| policy.is_needs_human(&agent_id, action))
458 .unwrap_or(false);
459 Some(AutonomyIdentity {
460 agent_id,
461 trace_id,
462 tier,
463 reviewers,
464 requires_human,
465 })
466}
467
468fn detail_for(action: SideEffectAction, args: &[VmValue]) -> JsonValue {
469 serde_json::json!({
470 "builtin": action.builtin,
471 "action_class": action.class,
472 "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
473 })
474}
475
476fn needs_human_detail(action: SideEffectAction, args: &[VmValue]) -> JsonValue {
477 let mut detail = detail_for(action, args);
478 if let Some(obj) = detail.as_object_mut() {
479 obj.insert(
480 "autonomy_class".to_string(),
481 JsonValue::String(NEEDS_HUMAN_AUTONOMY_CLASS.to_string()),
482 );
483 obj.insert("requires_human".to_string(), JsonValue::Bool(true));
484 obj.insert(
485 "deny_code".to_string(),
486 JsonValue::String(HARN_AUT_NEEDS_HUMAN_CODE.to_string()),
487 );
488 }
489 detail
490}
491
492async fn emit_proposal_event(
493 tier: AutonomyTier,
494 action: SideEffectAction,
495 args: &[VmValue],
496) -> Result<(), VmError> {
497 let Some(context) = current_dispatch_context() else {
498 return Ok(());
499 };
500 let Some(log) = active_event_log() else {
501 return Ok(());
502 };
503 let topic = Topic::new(crate::TRIGGER_OUTBOX_TOPIC)
504 .map_err(|error| VmError::Runtime(format!("autonomy proposal topic error: {error}")))?;
505 let mut headers = BTreeMap::new();
506 headers.insert(
507 "trace_id".to_string(),
508 context.trigger_event.trace_id.0.clone(),
509 );
510 headers.insert("agent".to_string(), context.agent_id.clone());
511 headers.insert("autonomy_tier".to_string(), tier.as_str().to_string());
512 let payload = serde_json::json!({
513 "agent": context.agent_id,
514 "action": context.action,
515 "builtin": action.builtin,
516 "action_class": action.class,
517 "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
518 "trace_id": context.trigger_event.trace_id.0,
519 "replay_of_event_id": context.replay_of_event_id,
520 "autonomy_tier": tier,
521 "proposal": true,
522 });
523 log.append(
524 &topic,
525 LogEvent::new("dispatch_proposed", payload).with_headers(headers),
526 )
527 .await
528 .map(|_| ())
529 .map_err(|error| VmError::Runtime(format!("failed to append autonomy proposal: {error}")))
530}
531
532async fn append_nonblocking_approval_request(
533 identity: &AutonomyIdentity,
534 action: SideEffectAction,
535 args: &[VmValue],
536) -> Result<String, VmError> {
537 let log = active_event_log().ok_or_else(|| {
538 categorized_error(
539 "autonomy approval requires an active event log",
540 ErrorCategory::ToolRejected,
541 )
542 })?;
543 append_approval_request_on(
544 &log,
545 identity.agent_id.clone(),
546 identity.trace_id.clone(),
547 action.class.to_string(),
548 detail_for(action, args),
549 identity.reviewers.clone(),
550 )
551 .await
552}
553
554async fn append_needs_human_approval_request(
559 identity: &AutonomyIdentity,
560 action: SideEffectAction,
561 args: &[VmValue],
562) -> Result<String, VmError> {
563 let log = active_event_log().ok_or_else(|| {
564 categorized_error(
565 "needs-human autonomy class requires an active event log",
566 ErrorCategory::ToolRejected,
567 )
568 })?;
569 append_approval_request_on(
570 &log,
571 identity.agent_id.clone(),
572 identity.trace_id.clone(),
573 format!("{}#needs-human", action.class),
574 needs_human_detail(action, args),
575 identity.reviewers.clone(),
576 )
577 .await
578}
579
580fn needs_human_deny_error(
585 identity: &AutonomyIdentity,
586 action: SideEffectAction,
587 request_id: &str,
588) -> VmError {
589 categorized_error(
590 format!(
591 "{code}: side effect `{builtin}` ({class}) is tagged `needs-human` for agent `{agent}`; \
592 auto-apply is forbidden regardless of autonomy tier `{tier}`. \
593 Approval request `{request_id}` was queued.",
594 code = HARN_AUT_NEEDS_HUMAN_CODE,
595 builtin = action.builtin,
596 class = action.class,
597 agent = identity.agent_id,
598 tier = identity.tier.as_str(),
599 request_id = request_id,
600 ),
601 ErrorCategory::ToolRejected,
602 )
603}
604
605struct ApprovalOutcome {
606 request_id: Option<String>,
607}
608
609async fn request_approval_before_effect(
610 identity: &AutonomyIdentity,
611 action: SideEffectAction,
612 args: &[VmValue],
613 harness: Option<&crate::harness::VmHarness>,
614) -> Result<ApprovalOutcome, VmError> {
615 active_event_log().ok_or_else(|| {
616 categorized_error(
617 "act_with_approval requires an active event log",
618 ErrorCategory::ToolRejected,
619 )
620 })?;
621 let detail = detail_for(action, args);
622 let approval = crate::stdlib::hitl::request_approval_for_side_effect(
623 harness,
624 action.class,
625 detail,
626 identity.agent_id.clone(),
627 identity.reviewers.clone(),
628 vec![action.capability.to_string()],
629 )
630 .await?;
631 let request_id = approval
632 .as_dict()
633 .and_then(|dict| dict.get("request_id"))
634 .map(VmValue::display);
635 Ok(ApprovalOutcome { request_id })
636}
637
638async fn append_enforcement_record(
639 identity: &AutonomyIdentity,
640 action: SideEffectAction,
641 args: &[VmValue],
642 outcome: TrustOutcome,
643 request_id: Option<String>,
644) -> Result<(), VmError> {
645 let Some(log) = active_event_log() else {
646 return Ok(());
647 };
648 let mut record = TrustRecord::new(
649 identity.agent_id.clone(),
650 action.class.to_string(),
651 None,
652 outcome,
653 identity.trace_id.clone(),
654 identity.tier,
655 );
656 let enforcement = if identity.requires_human {
657 "needs_human_denied"
661 } else {
662 match identity.tier {
663 AutonomyTier::Shadow => "shadow_noop",
664 AutonomyTier::Suggest => "suggest_approval_request",
665 AutonomyTier::ActWithApproval => "approval_granted",
666 AutonomyTier::ActAuto => "auto",
667 }
668 };
669 record.metadata.insert(
670 "autonomy.enforcement".to_string(),
671 serde_json::json!(enforcement),
672 );
673 record
674 .metadata
675 .insert("builtin".to_string(), serde_json::json!(action.builtin));
676 record
677 .metadata
678 .insert("action_class".to_string(), serde_json::json!(action.class));
679 let autonomy_class = if identity.requires_human {
684 NEEDS_HUMAN_AUTONOMY_CLASS.to_string()
685 } else {
686 identity.tier.as_str().to_string()
687 };
688 record.metadata.insert(
689 "autonomy_class".to_string(),
690 serde_json::json!(autonomy_class),
691 );
692 record.metadata.insert(
693 "requires_human".to_string(),
694 serde_json::json!(identity.requires_human),
695 );
696 if identity.requires_human {
697 record.metadata.insert(
698 "deny_code".to_string(),
699 serde_json::json!(HARN_AUT_NEEDS_HUMAN_CODE),
700 );
701 }
702 record.metadata.insert(
703 "args".to_string(),
704 serde_json::json!(args
705 .iter()
706 .map(crate::llm::vm_value_to_json)
707 .collect::<Vec<_>>()),
708 );
709 if let Some(request_id) = request_id {
710 record.metadata.insert(
711 "approval_request_id".to_string(),
712 serde_json::json!(request_id),
713 );
714 }
715 append_trust_record(&log, &record)
716 .await
717 .map(|_| ())
718 .map_err(|error| VmError::Runtime(format!("autonomy trust graph append: {error}")))
719}