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}