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