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 fn is_side_effecting_builtin(name: &str) -> bool {
186 side_effect_action_for_builtin(name).is_some()
187}
188
189pub fn needs_async_side_effect_enforcement(name: &str) -> bool {
190 let Some(action) = side_effect_action_for_builtin(name) else {
191 return false;
192 };
193 current_identity(&action)
194 .is_some_and(|identity| identity.requires_human || identity.tier != AutonomyTier::ActAuto)
198}
199
200pub fn enforce_builtin_side_effect_boxed<'a>(
201 name: &'a str,
202 args: &'a [VmValue],
203) -> Pin<Box<dyn Future<Output = Result<Option<AutonomyDecision>, VmError>> + 'a>> {
204 Box::pin(enforce_builtin_side_effect(name, args))
205}
206
207pub fn side_effect_action_for_builtin(name: &str) -> Option<SideEffectAction> {
208 first_workspace_write_action(
209 name,
210 &["write_file", "write_file_bytes", "append_file"],
211 "fs.write",
212 )
213 .or_else(|| first_workspace_write_action(name, &["mkdir"], "fs.mkdir"))
214 .or_else(|| first_workspace_write_action(name, &["mkdtemp"], "fs.mkdtemp"))
215 .or_else(|| first_workspace_write_action(name, &["copy_file"], "fs.copy"))
216 .or_else(|| first_matching_action(name, &["delete_file"], "fs.delete", "workspace.delete"))
217 .or_else(|| first_workspace_write_action(name, &["move_file"], "fs.move"))
218 .or_else(|| {
219 first_matching_action(
220 name,
221 &["exec", "exec_at", "shell", "shell_at"],
222 "process.exec",
223 "process.exec",
224 )
225 })
226 .or_else(|| first_matching_action(name, &["host_call"], "host.call", "host.call"))
227 .or_else(|| {
228 first_matching_action(
229 name,
230 &["store_set", "store_delete", "store_save", "store_clear"],
231 "store.write",
232 "store.write",
233 )
234 })
235 .or_else(|| {
236 first_matching_action(
237 name,
238 &[
239 "metadata_set",
240 "metadata_save",
241 "metadata_refresh_hashes",
242 "invalidate_facts",
243 "path_metadata_set",
244 ],
245 "metadata.write",
246 "metadata.write",
247 )
248 })
249 .or_else(|| {
250 first_matching_action(
251 name,
252 &["checkpoint", "checkpoint_delete", "checkpoint_clear"],
253 "checkpoint.write",
254 "checkpoint.write",
255 )
256 })
257 .or_else(|| {
258 first_matching_action(
259 name,
260 &[
261 "sse_server_response",
262 "sse_server_send",
263 "sse_server_heartbeat",
264 "sse_server_flush",
265 "sse_server_close",
266 "sse_server_cancel",
267 "sse_server_mock_receive",
268 "sse_server_mock_disconnect",
269 ],
270 "network.sse.write",
271 "network.sse",
272 )
273 })
274 .or_else(|| {
275 first_matching_action(
276 name,
277 &[
278 "__agent_state_write",
279 "__agent_state_delete",
280 "__agent_state_handoff",
281 ],
282 "agent_state.write",
283 "agent_state.write",
284 )
285 })
286 .or_else(|| first_matching_action(name, &["mcp_release"], "mcp.release", "mcp.release"))
287 .or_else(|| {
288 first_matching_action(
289 name,
290 &[
291 "git.worktree.create",
292 "git.worktree.remove",
293 "git.fetch",
294 "git.rebase",
295 "git.push",
296 ],
297 "git.write",
298 "git.write",
299 )
300 })
301}
302
303pub async fn enforce_builtin_side_effect(
304 name: &str,
305 args: &[VmValue],
306) -> Result<Option<AutonomyDecision>, VmError> {
307 let Some(action) = side_effect_action_for_builtin(name) else {
308 return Ok(None);
309 };
310 let Some(identity) = current_identity(&action) else {
311 return Ok(None);
312 };
313 if identity.requires_human {
317 emit_proposal_event(identity.tier, action, args).await?;
318 let request_id = append_needs_human_approval_request(&identity, action, args).await?;
319 append_enforcement_record(
320 &identity,
321 action,
322 args,
323 TrustOutcome::Denied,
324 Some(request_id.clone()),
325 )
326 .await?;
327 return Err(needs_human_deny_error(&identity, action, &request_id));
328 }
329 match identity.tier {
330 AutonomyTier::ActAuto => Ok(None),
331 AutonomyTier::Shadow => {
332 emit_proposal_event(identity.tier, action, args).await?;
333 append_enforcement_record(&identity, action, args, TrustOutcome::Denied, None).await?;
334 Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
335 }
336 AutonomyTier::Suggest => {
337 emit_proposal_event(identity.tier, action, args).await?;
338 let request_id = append_nonblocking_approval_request(&identity, action, args).await?;
339 append_enforcement_record(
340 &identity,
341 action,
342 args,
343 TrustOutcome::Denied,
344 Some(request_id),
345 )
346 .await?;
347 Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
348 }
349 AutonomyTier::ActWithApproval => {
350 let approval = request_approval_before_effect(&identity, action, args).await?;
351 append_enforcement_record(
352 &identity,
353 action,
354 args,
355 TrustOutcome::Success,
356 approval.request_id,
357 )
358 .await?;
359 Ok(Some(AutonomyDecision::AllowApproved))
360 }
361 }
362}
363
364fn current_identity(action: &SideEffectAction) -> Option<AutonomyIdentity> {
365 let scoped = current_autonomy_policy();
366 let dispatch = current_dispatch_context();
367 let agent_id = scoped
368 .as_ref()
369 .and_then(|policy| policy.agent_id.clone())
370 .or_else(|| dispatch.as_ref().map(|context| context.agent_id.clone()))
371 .unwrap_or_else(|| "runtime".to_string());
372 let tier = scoped
373 .as_ref()
374 .and_then(|policy| policy.effective_tier_for(&agent_id, action))
375 .or_else(|| dispatch.as_ref().map(|context| context.autonomy_tier))?;
376 let trace_id = dispatch
377 .as_ref()
378 .map(|context| context.trigger_event.trace_id.0.clone())
379 .unwrap_or_else(|| format!("trace-{}", Uuid::now_v7()));
380 let reviewers = scoped
381 .as_ref()
382 .map(|policy| policy.reviewers.clone())
383 .filter(|reviewers| !reviewers.is_empty())
384 .unwrap_or_default();
385 let requires_human = scoped
386 .as_ref()
387 .map(|policy| policy.is_needs_human(&agent_id, action))
388 .unwrap_or(false);
389 Some(AutonomyIdentity {
390 agent_id,
391 trace_id,
392 tier,
393 reviewers,
394 requires_human,
395 })
396}
397
398fn detail_for(action: SideEffectAction, args: &[VmValue]) -> JsonValue {
399 serde_json::json!({
400 "builtin": action.builtin,
401 "action_class": action.class,
402 "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
403 })
404}
405
406fn needs_human_detail(action: SideEffectAction, args: &[VmValue]) -> JsonValue {
407 let mut detail = detail_for(action, args);
408 if let Some(obj) = detail.as_object_mut() {
409 obj.insert(
410 "autonomy_class".to_string(),
411 JsonValue::String(NEEDS_HUMAN_AUTONOMY_CLASS.to_string()),
412 );
413 obj.insert("requires_human".to_string(), JsonValue::Bool(true));
414 obj.insert(
415 "deny_code".to_string(),
416 JsonValue::String(HARN_AUT_NEEDS_HUMAN_CODE.to_string()),
417 );
418 }
419 detail
420}
421
422async fn emit_proposal_event(
423 tier: AutonomyTier,
424 action: SideEffectAction,
425 args: &[VmValue],
426) -> Result<(), VmError> {
427 let Some(context) = current_dispatch_context() else {
428 return Ok(());
429 };
430 let Some(log) = active_event_log() else {
431 return Ok(());
432 };
433 let topic = Topic::new(crate::TRIGGER_OUTBOX_TOPIC)
434 .map_err(|error| VmError::Runtime(format!("autonomy proposal topic error: {error}")))?;
435 let mut headers = BTreeMap::new();
436 headers.insert(
437 "trace_id".to_string(),
438 context.trigger_event.trace_id.0.clone(),
439 );
440 headers.insert("agent".to_string(), context.agent_id.clone());
441 headers.insert("autonomy_tier".to_string(), tier.as_str().to_string());
442 let payload = serde_json::json!({
443 "agent": context.agent_id,
444 "action": context.action,
445 "builtin": action.builtin,
446 "action_class": action.class,
447 "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
448 "trace_id": context.trigger_event.trace_id.0,
449 "replay_of_event_id": context.replay_of_event_id,
450 "autonomy_tier": tier,
451 "proposal": true,
452 });
453 log.append(
454 &topic,
455 LogEvent::new("dispatch_proposed", payload).with_headers(headers),
456 )
457 .await
458 .map(|_| ())
459 .map_err(|error| VmError::Runtime(format!("failed to append autonomy proposal: {error}")))
460}
461
462async fn append_nonblocking_approval_request(
463 identity: &AutonomyIdentity,
464 action: SideEffectAction,
465 args: &[VmValue],
466) -> Result<String, VmError> {
467 let log = active_event_log().ok_or_else(|| {
468 categorized_error(
469 "autonomy approval requires an active event log",
470 ErrorCategory::ToolRejected,
471 )
472 })?;
473 append_approval_request_on(
474 &log,
475 identity.agent_id.clone(),
476 identity.trace_id.clone(),
477 action.class.to_string(),
478 detail_for(action, args),
479 identity.reviewers.clone(),
480 )
481 .await
482}
483
484async fn append_needs_human_approval_request(
489 identity: &AutonomyIdentity,
490 action: SideEffectAction,
491 args: &[VmValue],
492) -> Result<String, VmError> {
493 let log = active_event_log().ok_or_else(|| {
494 categorized_error(
495 "needs-human autonomy class requires an active event log",
496 ErrorCategory::ToolRejected,
497 )
498 })?;
499 append_approval_request_on(
500 &log,
501 identity.agent_id.clone(),
502 identity.trace_id.clone(),
503 format!("{}#needs-human", action.class),
504 needs_human_detail(action, args),
505 identity.reviewers.clone(),
506 )
507 .await
508}
509
510fn needs_human_deny_error(
515 identity: &AutonomyIdentity,
516 action: SideEffectAction,
517 request_id: &str,
518) -> VmError {
519 categorized_error(
520 format!(
521 "{code}: side effect `{builtin}` ({class}) is tagged `needs-human` for agent `{agent}`; \
522 auto-apply is forbidden regardless of autonomy tier `{tier}`. \
523 Approval request `{request_id}` was queued.",
524 code = HARN_AUT_NEEDS_HUMAN_CODE,
525 builtin = action.builtin,
526 class = action.class,
527 agent = identity.agent_id,
528 tier = identity.tier.as_str(),
529 request_id = request_id,
530 ),
531 ErrorCategory::ToolRejected,
532 )
533}
534
535struct ApprovalOutcome {
536 request_id: Option<String>,
537}
538
539async fn request_approval_before_effect(
540 identity: &AutonomyIdentity,
541 action: SideEffectAction,
542 args: &[VmValue],
543) -> Result<ApprovalOutcome, VmError> {
544 active_event_log().ok_or_else(|| {
545 categorized_error(
546 "act_with_approval requires an active event log",
547 ErrorCategory::ToolRejected,
548 )
549 })?;
550 let detail = detail_for(action, args);
551 let approval = crate::stdlib::hitl::request_approval_for_side_effect(
552 action.class,
553 detail,
554 identity.agent_id.clone(),
555 identity.reviewers.clone(),
556 vec![action.capability.to_string()],
557 )
558 .await?;
559 let request_id = approval
560 .as_dict()
561 .and_then(|dict| dict.get("request_id"))
562 .map(VmValue::display);
563 Ok(ApprovalOutcome { request_id })
564}
565
566async fn append_enforcement_record(
567 identity: &AutonomyIdentity,
568 action: SideEffectAction,
569 args: &[VmValue],
570 outcome: TrustOutcome,
571 request_id: Option<String>,
572) -> Result<(), VmError> {
573 let Some(log) = active_event_log() else {
574 return Ok(());
575 };
576 let mut record = TrustRecord::new(
577 identity.agent_id.clone(),
578 action.class.to_string(),
579 None,
580 outcome,
581 identity.trace_id.clone(),
582 identity.tier,
583 );
584 let enforcement = if identity.requires_human {
585 "needs_human_denied"
589 } else {
590 match identity.tier {
591 AutonomyTier::Shadow => "shadow_noop",
592 AutonomyTier::Suggest => "suggest_approval_request",
593 AutonomyTier::ActWithApproval => "approval_granted",
594 AutonomyTier::ActAuto => "auto",
595 }
596 };
597 record.metadata.insert(
598 "autonomy.enforcement".to_string(),
599 serde_json::json!(enforcement),
600 );
601 record
602 .metadata
603 .insert("builtin".to_string(), serde_json::json!(action.builtin));
604 record
605 .metadata
606 .insert("action_class".to_string(), serde_json::json!(action.class));
607 let autonomy_class = if identity.requires_human {
612 NEEDS_HUMAN_AUTONOMY_CLASS.to_string()
613 } else {
614 identity.tier.as_str().to_string()
615 };
616 record.metadata.insert(
617 "autonomy_class".to_string(),
618 serde_json::json!(autonomy_class),
619 );
620 record.metadata.insert(
621 "requires_human".to_string(),
622 serde_json::json!(identity.requires_human),
623 );
624 if identity.requires_human {
625 record.metadata.insert(
626 "deny_code".to_string(),
627 serde_json::json!(HARN_AUT_NEEDS_HUMAN_CODE),
628 );
629 }
630 record.metadata.insert(
631 "args".to_string(),
632 serde_json::json!(args
633 .iter()
634 .map(crate::llm::vm_value_to_json)
635 .collect::<Vec<_>>()),
636 );
637 if let Some(request_id) = request_id {
638 record.metadata.insert(
639 "approval_request_id".to_string(),
640 serde_json::json!(request_id),
641 );
642 }
643 append_trust_record(&log, &record)
644 .await
645 .map(|_| ())
646 .map_err(|error| VmError::Runtime(format!("autonomy trust graph append: {error}")))
647}