Skip to main content

atman_runtime/
approval.rs

1use 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}