mermaid_cli/providers/
auto_classifier.rs1use std::sync::Arc;
19use std::time::Duration;
20
21use async_trait::async_trait;
22use tokio_util::sync::CancellationToken;
23
24use crate::domain::{ChatRequest, TurnId};
25use crate::models::{ChatMessage, ReasoningLevel};
26use crate::providers::factory::ProviderFactory;
27
28const VET_TIMEOUT: Duration = Duration::from_secs(10);
30const VET_MAX_TOKENS: usize = 150;
32
33const SYSTEM_PROMPT: &str = "You are a safety reviewer for an AI coding agent running in \"auto\" mode. \
34The agent has already decided to take an action; your job is to wave through the routine, aligned ones \
35and stop only the genuinely risky or off-task ones. Bias strongly toward ALLOW: most actions an engineer \
36would expect while pursuing the stated goal should pass. ESCALATE only when an action is destructive, \
37leaks secrets or credentials, reaches untrusted network endpoints, modifies shared/production \
38infrastructure, or clearly does not serve the user's goal. When in doubt about real risk, ESCALATE. \
39Reply with EXACTLY one line: `ALLOW` or `ESCALATE: <short reason>`.";
40
41#[derive(Debug, Clone)]
43pub struct VetRequest {
44 pub tool: String,
45 pub summary: String,
46 pub command: Option<String>,
47 pub path: Option<String>,
48 pub intent: Option<String>,
50 pub workdir: String,
52 pub turn: TurnId,
53 pub token: CancellationToken,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct VetVerdict {
60 pub allow: bool,
61 pub reason: String,
62}
63
64impl VetVerdict {
65 pub fn allow() -> Self {
66 Self {
67 allow: true,
68 reason: String::new(),
69 }
70 }
71 pub fn escalate(reason: impl Into<String>) -> Self {
72 Self {
73 allow: false,
74 reason: reason.into(),
75 }
76 }
77}
78
79#[async_trait]
82pub trait AutoClassifier: Send + Sync {
83 async fn vet(&self, req: &VetRequest) -> VetVerdict;
84}
85
86pub struct ModelAutoClassifier {
89 providers: Arc<ProviderFactory>,
90 model_id: String,
91}
92
93impl ModelAutoClassifier {
94 pub fn new(providers: Arc<ProviderFactory>, model_id: String) -> Self {
95 Self {
96 providers,
97 model_id,
98 }
99 }
100
101 fn build_request(&self, req: &VetRequest) -> ChatRequest {
102 let action = describe_action(req);
103 let intent = req
104 .intent
105 .as_deref()
106 .map(str::trim)
107 .filter(|s| !s.is_empty())
108 .unwrap_or("(no explicit goal stated this turn)");
109 let user = format!(
110 "Working directory: {wd}\n\nUser's current goal:\n{intent}\n\nProposed action:\n{action}\n\n\
111 Does this action plausibly serve the user's goal and look safe to run automatically?",
112 wd = req.workdir,
113 intent = intent,
114 action = action,
115 );
116 ChatRequest {
117 model_id: self.model_id.clone(),
118 messages: vec![ChatMessage::user(user)],
119 system_prompt: SYSTEM_PROMPT.to_string(),
120 instructions: None,
121 reasoning: ReasoningLevel::None,
124 temperature: 0.0,
125 max_tokens: VET_MAX_TOKENS,
126 tools: Vec::new(),
127 }
128 }
129}
130
131#[async_trait]
132impl AutoClassifier for ModelAutoClassifier {
133 async fn vet(&self, req: &VetRequest) -> VetVerdict {
134 let request = self.build_request(req);
135 let providers = Arc::clone(&self.providers);
136 let model_id = self.model_id.clone();
137 let turn = req.turn;
138 let token = req.token.clone();
139
140 let call = async move {
141 let provider = providers.resolve(&model_id).await?;
142 let (text, _usage) =
143 crate::providers::model::collect_text(provider, turn, request, token).await?;
144 Ok::<String, crate::models::ModelError>(text)
145 };
146
147 match tokio::time::timeout(VET_TIMEOUT, call).await {
148 Ok(Ok(text)) => parse_verdict(&text),
149 Ok(Err(err)) => VetVerdict::escalate(format!("classifier unavailable: {err}")),
150 Err(_) => VetVerdict::escalate("classifier timed out"),
151 }
152 }
153}
154
155fn describe_action(req: &VetRequest) -> String {
156 if let Some(cmd) = &req.command {
157 format!("Tool `{}` will run a shell command:\n {}", req.tool, cmd)
158 } else if let Some(path) = &req.path {
159 format!(
160 "Tool `{}` will act on path `{}` ({})",
161 req.tool, path, req.summary
162 )
163 } else {
164 format!("Tool `{}`: {}", req.tool, req.summary)
165 }
166}
167
168fn parse_verdict(text: &str) -> VetVerdict {
171 let trimmed = text.trim();
172 if trimmed.is_empty() {
173 return VetVerdict::escalate("classifier returned an empty response");
174 }
175 let upper = trimmed.to_ascii_uppercase();
176 if upper.starts_with("ALLOW") {
177 return VetVerdict::allow();
178 }
179 if upper.starts_with("ESCALATE") {
180 let reason = trimmed
181 .split_once(':')
182 .map(|(_, r)| r.trim())
183 .filter(|r| !r.is_empty())
184 .map(clip)
185 .unwrap_or_else(|| "flagged by the safety classifier".to_string());
186 return VetVerdict::escalate(reason);
187 }
188 VetVerdict::escalate(format!("unrecognized classifier reply: {}", clip(trimmed)))
189}
190
191fn clip(s: &str) -> String {
193 const MAX: usize = 160;
194 if s.len() <= MAX {
195 return s.to_string();
196 }
197 let cut = s.floor_char_boundary(MAX);
198 format!("{}…", &s[..cut])
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn allow_parses() {
207 assert!(parse_verdict("ALLOW").allow);
208 assert!(parse_verdict(" allow\n").allow);
209 assert!(parse_verdict("Allow — looks fine").allow);
210 }
211
212 #[test]
213 fn escalate_parses_with_reason() {
214 let v = parse_verdict("ESCALATE: pipes a remote script into sh");
215 assert!(!v.allow);
216 assert_eq!(v.reason, "pipes a remote script into sh");
217 }
218
219 #[test]
220 fn escalate_without_reason_has_default() {
221 let v = parse_verdict("escalate");
222 assert!(!v.allow);
223 assert!(!v.reason.is_empty());
224 }
225
226 #[test]
227 fn garbage_and_empty_fail_safe() {
228 for reply in ["", " ", "maybe?", "yes", "no", "I think it's fine"] {
230 assert!(
231 !parse_verdict(reply).allow,
232 "expected escalate (fail-safe) for {reply:?}",
233 );
234 }
235 }
236}