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
16pub const HARN_AUT_NEEDS_HUMAN_CODE: &str = "HARN-AUT-NEEDS-HUMAN";
20
21pub 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 #[serde(default)]
44 pub requires_human: bool,
45 #[serde(default, alias = "action_requires_human")]
50 pub requires_human_actions: BTreeSet<String>,
51 #[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 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 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
185pub(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 .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 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
502async 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
528fn 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_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 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}