Skip to main content

matrixcode_core/
approval.rs

1//! Approval gate: interactive confirmation before executing mutating or dangerous tools.
2//!
3//! Three modes:
4//! - `Auto`: execute everything without asking (trust the AI).
5//! - `Ask` (default): pause before mutating/dangerous operations.
6//! - `Strict`: pause before every tool call.
7
8use std::fmt;
9use std::io::{self, BufRead, Write as _};
10
11use crate::truncate::truncate_with_suffix;
12use serde_json::Value;
13
14// ============================================================================
15// Risk Level
16// ============================================================================
17
18/// Risk level assigned to each tool operation.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
20pub enum RiskLevel {
21    /// Read-only, no side effects (e.g., read, search, glob, ls).
22    Safe,
23    /// Modifies files but in a controlled way (e.g., write, edit, multi_edit, todo_write).
24    Mutating,
25    /// Potentially dangerous or irreversible (e.g., bash commands).
26    Dangerous,
27}
28
29impl fmt::Display for RiskLevel {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            RiskLevel::Safe => write!(f, "safe"),
33            RiskLevel::Mutating => write!(f, "mutating"),
34            RiskLevel::Dangerous => write!(f, "dangerous"),
35        }
36    }
37}
38
39impl RiskLevel {
40    /// Get the icon symbol for this risk level.
41    pub fn icon(&self) -> &'static str {
42        match self {
43            RiskLevel::Safe => "โ„น๏ธ ",
44            RiskLevel::Mutating => "๐Ÿ“",
45            RiskLevel::Dangerous => "โš ๏ธ ",
46        }
47    }
48}
49
50// ============================================================================
51// Approve Mode
52// ============================================================================
53
54/// Approval mode controlling when the user is prompted.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
56pub enum ApproveMode {
57    /// Never ask, execute everything automatically.
58    Auto,
59    /// Ask before mutating and dangerous operations (default).
60    #[default]
61    Ask,
62    /// Ask before every tool call, including safe ones.
63    Strict,
64}
65
66impl ApproveMode {
67    pub fn parse(s: &str) -> Self {
68        match s.to_lowercase().as_str() {
69            "auto" => ApproveMode::Auto,
70            "strict" => ApproveMode::Strict,
71            _ => ApproveMode::Ask,
72        }
73    }
74
75    /// Cycle to the next mode: Ask -> Auto -> Strict -> Ask
76    pub fn next(&self) -> Self {
77        match self {
78            ApproveMode::Ask => ApproveMode::Auto,
79            ApproveMode::Auto => ApproveMode::Strict,
80            ApproveMode::Strict => ApproveMode::Ask,
81        }
82    }
83
84    /// Convert to u8 for atomic storage.
85    pub fn to_u8(self) -> u8 {
86        match self {
87            ApproveMode::Auto => 0,
88            ApproveMode::Ask => 1,
89            ApproveMode::Strict => 2,
90        }
91    }
92
93    /// Convert from u8 (atomic load).
94    pub fn from_u8(v: u8) -> Self {
95        match v {
96            0 => ApproveMode::Auto,
97            2 => ApproveMode::Strict,
98            _ => ApproveMode::Ask,
99        }
100    }
101}
102
103impl fmt::Display for ApproveMode {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            ApproveMode::Auto => write!(f, "auto"),
107            ApproveMode::Ask => write!(f, "ask"),
108            ApproveMode::Strict => write!(f, "strict"),
109        }
110    }
111}
112
113// ============================================================================
114// Approval Answer
115// ============================================================================
116
117/// User's response to an approval prompt.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum ApprovalAnswer {
120    /// Proceed with execution.
121    Yes,
122    /// Skip this tool call (return a "rejected" message to the AI).
123    No,
124    /// Abort the entire turn.
125    Abort,
126}
127
128// ============================================================================
129// Approval Request
130// ============================================================================
131
132/// A human-readable summary of what is about to happen.
133#[derive(Debug, Clone)]
134pub struct ApprovalRequest {
135    pub tool_name: String,
136    pub risk_level: RiskLevel,
137    pub summary: String,
138}
139
140impl ApprovalRequest {
141    /// Build an approval request from tool name, risk level, and parameters.
142    pub fn new(tool_name: &str, risk: RiskLevel, params: &Value) -> Self {
143        Self {
144            tool_name: tool_name.to_string(),
145            risk_level: risk,
146            summary: build_summary(tool_name, params),
147        }
148    }
149}
150
151/// Build a human-readable summary for the tool operation.
152fn build_summary(tool_name: &str, params: &Value) -> String {
153    match tool_name {
154        "write" => summary_write(params),
155        "edit" => summary_edit(params),
156        "multi_edit" => summary_multi_edit(params),
157        "bash" => summary_bash(params),
158        "todo_write" => "ๆ›ดๆ–ฐไปปๅŠกๆธ…ๅ•".to_string(),
159        _ => format!("ๆ‰ง่กŒๅทฅๅ…ท: {}", tool_name),
160    }
161}
162
163fn summary_write(params: &Value) -> String {
164    let path = params["path"].as_str().unwrap_or("<unknown>");
165    format!("ๅ†™ๅ…ฅๆ–‡ไปถ: {}", path)
166}
167
168fn summary_edit(params: &Value) -> String {
169    let path = params["path"].as_str().unwrap_or("<unknown>");
170    format!("็ผ–่พ‘ๆ–‡ไปถ: {}", path)
171}
172
173fn summary_multi_edit(params: &Value) -> String {
174    let path = params["path"].as_str().unwrap_or("<unknown>");
175    let count = params["edits"].as_array().map(|a| a.len()).unwrap_or(0);
176    format!("ๆ‰น้‡็ผ–่พ‘ๆ–‡ไปถ: {} ({} ๅค„ไฟฎๆ”น)", path, count)
177}
178
179fn summary_bash(params: &Value) -> String {
180    let cmd = params["command"].as_str().unwrap_or("<unknown>");
181    let display_cmd = if cmd.len() > 120 {
182        truncate_with_suffix(cmd, 120)
183    } else {
184        cmd.to_string()
185    };
186    format!("ๆ‰ง่กŒๅ‘ฝไปค: {}", display_cmd)
187}
188
189// ============================================================================
190// Core Functions
191// ============================================================================
192
193/// Determine whether approval is needed given the mode and risk level.
194pub fn needs_approval(mode: ApproveMode, risk: RiskLevel) -> bool {
195    match mode {
196        ApproveMode::Auto => false,
197        ApproveMode::Ask => risk >= RiskLevel::Mutating,
198        ApproveMode::Strict => true,
199    }
200}
201
202/// Convenience function: build request and prompt user.
203pub fn build_approval_request(tool_name: &str, risk: RiskLevel, params: &Value) -> ApprovalRequest {
204    ApprovalRequest::new(tool_name, risk, params)
205}
206
207/// Display the approval prompt and wait for user input.
208/// Returns the user's answer.
209pub fn prompt_approval(request: &ApprovalRequest) -> ApprovalAnswer {
210    println!();
211    println!("โ”Œโ”€ ็กฎ่ฎค่ฏทๆฑ‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€");
212    println!("โ”‚ {} {}", request.risk_level.icon(), request.summary);
213    println!("โ”‚ ้ฃŽ้™ฉ็ญ‰็บง: {}", request.risk_level);
214    println!("โ”‚");
215    println!("โ”‚ [y] ๆ‰ง่กŒ  [n] ่ทณ่ฟ‡  [a] ไธญๆญขๆœฌ่ฝฎ");
216    println!("โ””โ”€โ”€โ”€๏ฟฝ๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€");
217    print!("> ");
218    let _ = io::stdout().flush();
219
220    let answer = read_approval_answer();
221    println!();
222    answer
223}
224
225/// Read a single answer from stdin.
226fn read_approval_answer() -> ApprovalAnswer {
227    let stdin = io::stdin();
228    let mut line = String::new();
229    if stdin.lock().read_line(&mut line).is_err() {
230        return ApprovalAnswer::No;
231    }
232    match line.trim().to_lowercase().as_str() {
233        "y" | "yes" | "" => ApprovalAnswer::Yes,
234        "n" | "no" => ApprovalAnswer::No,
235        "a" | "abort" | "q" | "quit" => ApprovalAnswer::Abort,
236        _ => ApprovalAnswer::Yes, // default to yes for unrecognized input
237    }
238}