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 seq: 0,
109 run_id: run_id.clone(),
110 tool_use_id: id.to_string(),
111 tool_name: name.to_string(),
112 args_preview: args_preview.clone(),
113 level: level_str(level).into(),
114 preview: preview.clone(),
115 ts: chrono::Utc::now(),
116 });
117 }
118 if let Some(tx) = &ctx.stream_tx {
119 let _ = tx.send(crate::stream::StreamFrame::ToolPendingApproval {
120 run_id: run_id.0.to_string(),
121 tool_use_id: id.to_string(),
122 tool_name: name.to_string(),
123 args_preview,
124 level: level_str(level).into(),
125 preview: preview.clone(),
126 });
127 }
128 let decision = rx.await.unwrap_or(crate::session::ApprovalDecision::Deny {
129 reason: "approval channel dropped".into(),
130 });
131 match decision {
132 crate::session::ApprovalDecision::Approve => {
133 if let Some(sink) = ctx.events.as_ref() {
134 sink.emit(crate::event::Event::ToolApproved {
135 seq: 0,
136 run_id: run_id.clone(),
137 tool_use_id: id.to_string(),
138 decided_by: "user".into(),
139 ts: chrono::Utc::now(),
140 });
141 }
142 if let Some(tx) = &ctx.stream_tx {
143 let _ = tx.send(crate::stream::StreamFrame::ToolApproved {
144 run_id: run_id.0.to_string(),
145 tool_use_id: id.to_string(),
146 decided_by: "user".into(),
147 });
148 }
149 ApprovalOutcome::Approve
150 }
151 crate::session::ApprovalDecision::Deny { reason } => {
152 if let Some(sink) = ctx.events.as_ref() {
153 sink.emit(crate::event::Event::ToolDenied {
154 seq: 0,
155 run_id: run_id.clone(),
156 tool_use_id: id.to_string(),
157 reason: reason.clone(),
158 ts: chrono::Utc::now(),
159 });
160 }
161 if let Some(tx) = &ctx.stream_tx {
162 let _ = tx.send(crate::stream::StreamFrame::ToolDenied {
163 run_id: run_id.0.to_string(),
164 tool_use_id: id.to_string(),
165 reason: reason.clone(),
166 });
167 }
168 ApprovalOutcome::Deny { reason }
169 }
170 }
171}