atman_runtime/
approval.rs1use crate::tool::{ApprovalLevel, ToolArgs, ToolCtx};
2
3pub enum ApprovalOutcome {
4 Approve,
5 Deny { reason: String },
6}
7
8pub fn level_str(level: ApprovalLevel) -> &'static str {
9 match level {
10 ApprovalLevel::Auto => "auto",
11 ApprovalLevel::Approve => "approve",
12 ApprovalLevel::Dangerous => "dangerous",
13 }
14}
15
16fn is_outside_workspace(_ctx: &ToolCtx, tool_name: &str, args: &ToolArgs) -> bool {
17 if !matches!(tool_name, "fs.write" | "fs.edit" | "fs.grep") {
18 return false;
19 }
20 let path = match args.named("path").or_else(|| args.positional(0).ok()) {
21 Some(crate::value::Value::Path(p)) => p.clone(),
22 Some(crate::value::Value::Str(s)) => std::path::PathBuf::from(s),
23 _ => return false,
24 };
25 let abs = if path.is_absolute() {
26 path
27 } else {
28 match std::env::current_dir() {
29 Ok(cwd) => cwd.join(&path),
30 Err(_) => return true,
31 }
32 };
33 match std::env::current_dir() {
34 Ok(cwd) => !abs.starts_with(&cwd),
35 Err(_) => true,
36 }
37}
38
39pub async fn request_approval(
40 ctx: &ToolCtx,
41 id: &str,
42 name: &str,
43 call_args: &ToolArgs,
44 level: ApprovalLevel,
45 tool: Option<&dyn crate::tool::Tool>,
46) -> ApprovalOutcome {
47 use crate::trust::{OutsideBehavior, TrustMode};
48 let outside_workspace = is_outside_workspace(ctx, name, call_args);
49 if outside_workspace {
50 if let Some(trust) = &ctx.trust {
51 if trust.mode != TrustMode::Reckless {
52 match trust.outside {
53 OutsideBehavior::Deny => {
54 return ApprovalOutcome::Deny {
55 reason: format!(
56 "{name}: blocked — path outside workspace and outside=deny"
57 ),
58 };
59 }
60 OutsideBehavior::Allow => {}
61 OutsideBehavior::Approve => {}
62 }
63 }
64 }
65 }
66 let Some(approval) = &ctx.approval else {
67 return ApprovalOutcome::Approve;
68 };
69 let Some(run_id) = ctx.flow_run_id.clone() else {
70 return ApprovalOutcome::Approve;
71 };
72 let force_manual = outside_workspace
73 && ctx
74 .trust
75 .as_ref()
76 .map(|t| t.mode != TrustMode::Reckless && t.outside == OutsideBehavior::Approve)
77 .unwrap_or(true);
78 let effective_level = if force_manual {
79 ApprovalLevel::Dangerous
80 } else {
81 level
82 };
83 let args_preview: String = format!("{:?}", call_args.named)
84 .chars()
85 .take(4000)
86 .collect();
87 let preview = if level == ApprovalLevel::Auto {
88 None
89 } else {
90 match tool {
91 Some(t) => t.preview_call(call_args, ctx).await,
92 None => None,
93 }
94 };
95 let pending = crate::session::PendingApproval {
96 tool_use_id: id.to_string(),
97 tool_name: name.to_string(),
98 args_preview: args_preview.clone(),
99 preview: preview.clone(),
100 level: effective_level,
101 run_id: run_id.clone(),
102 emitted_at: chrono::Utc::now(),
103 bypass_auto_ceiling: force_manual,
104 };
105 let rx = approval.request(pending);
106 if let Some(sink) = ctx.events.as_ref() {
107 sink.emit(crate::event::Event::ToolPendingApproval {
108 run_id: run_id.clone(),
109 tool_use_id: id.to_string(),
110 tool_name: name.to_string(),
111 args_preview: args_preview.clone(),
112 level: level_str(level).into(),
113 preview: preview.clone(),
114 });
115 }
116 if let Some(tx) = &ctx.stream_tx {
117 let _ = tx.send(crate::stream::StreamFrame::ToolPendingApproval {
118 run_id: run_id.0.to_string(),
119 tool_use_id: id.to_string(),
120 tool_name: name.to_string(),
121 args_preview,
122 level: level_str(level).into(),
123 preview: preview.clone(),
124 });
125 }
126 let decision = rx.await.unwrap_or(crate::session::ApprovalDecision::Deny {
127 reason: "approval channel dropped".into(),
128 });
129 match decision {
130 crate::session::ApprovalDecision::Approve => {
131 if let Some(sink) = ctx.events.as_ref() {
132 sink.emit(crate::event::Event::ToolApproved {
133 run_id: run_id.clone(),
134 tool_use_id: id.to_string(),
135 decided_by: "user".into(),
136 });
137 }
138 if let Some(tx) = &ctx.stream_tx {
139 let _ = tx.send(crate::stream::StreamFrame::ToolApproved {
140 run_id: run_id.0.to_string(),
141 tool_use_id: id.to_string(),
142 decided_by: "user".into(),
143 });
144 }
145 ApprovalOutcome::Approve
146 }
147 crate::session::ApprovalDecision::Deny { reason } => {
148 if let Some(sink) = ctx.events.as_ref() {
149 sink.emit(crate::event::Event::ToolDenied {
150 run_id: run_id.clone(),
151 tool_use_id: id.to_string(),
152 reason: reason.clone(),
153 });
154 }
155 if let Some(tx) = &ctx.stream_tx {
156 let _ = tx.send(crate::stream::StreamFrame::ToolDenied {
157 run_id: run_id.0.to_string(),
158 tool_use_id: id.to_string(),
159 reason: reason.clone(),
160 });
161 }
162 ApprovalOutcome::Deny { reason }
163 }
164 }
165}