返回 CodeWhale
reviewer.rs
根目录 / crates / tui / src / core / engine / reviewer.rs
1 //! Model-backed Auto-Review guardian tier (v0.9.8).
2 //!
3 //! The deterministic policy engine (see [`crate::tui::auto_review`]) decides
4 //! first: configured block rules and the built-in safety floor are hard
5 //! blocks that never reach a model. Only deterministic *fallback holds* — the
6 //! `AskUser` outcomes Auto posture would otherwise turn into bare permission
7 //! denials — escalate to a one-shot reviewer request. A denial returns the
8 //! rationale to the agent with an explicit "do not work around" instruction;
9 //! and any reviewer failure (timeout, transport error, unparseable answer)
10 //! is a denial — fail closed. The reviewer is deliberately stateless: each
11 //! proposed call stands on its own deterministic context.
12
13 use std::time::Duration;
14
15 use crate::core::model_client::ModelClient;
16 use crate::tools::spec::ToolError;
17 use crate::tui::auto_review::{
18 AutoReviewAction, DEFAULT_GUARDIAN_POLICY, ReviewerRiskLevel, ReviewerVerdict,
19 parse_reviewer_verdict,
20 };
21 use codewhale_models::Role;
22 use codewhale_models::{
23 ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Usage,
24 is_incomplete_stop_reason,
25 };
26 use tokio_util::sync::CancellationToken;
27
28 /// One-shot reviewer deadline. Slow reviewers are denials, surfaced
29 /// separately from explicit denials (a timeout proves nothing about safety).
30 const REVIEWER_TIMEOUT: Duration = Duration::from_secs(90);
31 /// Keep one exact held call comfortably inside every supported model context.
32 /// Truncating tool input could hide the unsafe part, so oversized reviews deny.
33 const MAX_REVIEW_CONTEXT_BYTES: usize = 64 * 1024;
34
35 /// The reviewer's answer, with failure modes separated from explicit denials.
36 #[derive(Debug, Clone, PartialEq, Eq)]
37 pub(crate) enum ReviewerOutcome {
38 Allow {
39 risk: ReviewerRiskLevel,
40 reason: String,
41 },
42 Deny {
43 risk: ReviewerRiskLevel,
44 reason: String,
45 },
46 /// Timeout, transport error, or unparseable answer. Always a denial.
47 Unavailable {
48 reason: String,
49 },
50 Cancelled,
51 }
52
53 impl ReviewerOutcome {
54 pub(crate) fn audit_decision(&self) -> &'static str {
55 match self {
56 Self::Allow { .. } => "allow",
57 Self::Deny { .. } => "deny",
58 Self::Unavailable { .. } => "unavailable",
59 Self::Cancelled => "cancelled",
60 }
61 }
62
63 pub(crate) fn audit_risk(&self) -> Option<&'static str> {
64 match self {
65 Self::Allow { risk, .. } | Self::Deny { risk, .. } => Some(risk.as_str()),
66 Self::Unavailable { .. } | Self::Cancelled => None,
67 }
68 }
69
70 pub(crate) fn into_tool_result(self, tool_name: &str) -> Result<String, ToolError> {
71 match self {
72 Self::Allow { reason, .. } => Ok(reason),
73 Self::Deny { reason, .. } => Err(ToolError::permission_denied(format!(
74 "Auto-Review guardian denied tool '{tool_name}': {reason}. Do not work around this denial; find a materially safer path or stop."
75 ))),
76 Self::Unavailable { reason } => Err(ToolError::permission_denied(format!(
77 "Auto-Review guardian unavailable ({reason}); the call was denied (fail closed). Switch to Ask to review this call yourself."
78 ))),
79 Self::Cancelled => Err(ToolError::cancelled(
80 "Auto-Review guardian request cancelled",
81 )),
82 }
83 }
84 }
85
86 /// One guardian review and its provider usage, when a request was dispatched.
87 #[derive(Debug, Clone, PartialEq, Eq)]
88 pub(crate) struct ReviewerResult {
89 pub(crate) outcome: ReviewerOutcome,
90 pub(crate) usage: Option<Usage>,
91 }
92
93 impl ReviewerResult {
94 fn finish(outcome: ReviewerOutcome, usage: Option<Usage>) -> Self {
95 Self { outcome, usage }
96 }
97
98 fn unavailable(reason: impl Into<String>, usage: Option<Usage>) -> Self {
99 Self::finish(
100 ReviewerOutcome::Unavailable {
101 reason: reason.into(),
102 },
103 usage,
104 )
105 }
106 }
107
108 /// Ask the model guardian for one decision. `context_text` carries the
109 /// deterministic hold and the call under review; the system prompt is fixed.
110 pub(crate) async fn consult_reviewer(
111 client: &dyn ModelClient,
112 context_text: &str,
113 cancel_token: &CancellationToken,
114 ) -> ReviewerResult {
115 if context_text.len() > MAX_REVIEW_CONTEXT_BYTES {
116 return ReviewerResult::unavailable(
117 "the exact review context exceeded the guardian limit",
118 None,
119 );
120 }
121 let request = MessageRequest {
122 model: client.model().to_string(),
123 messages: vec![Message {
124 role: Role::User,
125 content: vec![ContentBlock::Text {
126 text: context_text.to_string(),
127 cache_control: None,
128 }],
129 }],
130 max_tokens: 384,
131 system: Some(SystemPrompt::Text(DEFAULT_GUARDIAN_POLICY.to_string())),
132 tools: None,
133 tool_choice: None,
134 metadata: None,
135 thinking: None,
136 reasoning_effort: None,
137 stream: Some(false),
138 temperature: Some(0.0),
139 top_p: None,
140 };
141 if cancel_token.is_cancelled() {
142 return ReviewerResult::finish(ReviewerOutcome::Cancelled, None);
143 }
144 let response = tokio::select! {
145 biased;
146 _ = cancel_token.cancelled() => {
147 return ReviewerResult::finish(ReviewerOutcome::Cancelled, None);
148 }
149 response = tokio::time::timeout(REVIEWER_TIMEOUT, client.create_message_uncached(request)) => response,
150 };
151 let response = match response {
152 Err(_) => return ReviewerResult::unavailable("the reviewer timed out", None),
153 // Provider errors can include response bodies or credential-shaped
154 // details. The guardian needs only the fail-closed outcome.
155 Ok(Err(_)) => return ReviewerResult::unavailable("the reviewer request failed", None),
156 Ok(Ok(response)) => response,
157 };
158 let outcome = verdict_from_response(&response);
159 ReviewerResult::finish(outcome, Some(response.usage))
160 }
161
162 fn verdict_from_response(response: &MessageResponse) -> ReviewerOutcome {
163 if is_incomplete_stop_reason(response.stop_reason.as_deref()) {
164 return ReviewerOutcome::Unavailable {
165 reason: "the reviewer answer was incomplete".to_string(),
166 };
167 }
168 let text: String = response
169 .content
170 .iter()
171 .filter_map(|block| match block {
172 ContentBlock::Text { text, .. } => Some(text.as_str()),
173 _ => None,
174 })
175 .collect::<Vec<_>>()
176 .join("\n");
177 match parse_reviewer_verdict(&text) {
178 Some(ReviewerVerdict {
179 action: AutoReviewAction::Allow,
180 risk,
181 reason,
182 }) if risk.may_auto_run() => ReviewerOutcome::Allow { risk, reason },
183 Some(ReviewerVerdict {
184 action: AutoReviewAction::Allow,
185 risk,
186 ..
187 }) => ReviewerOutcome::Deny {
188 risk,
189 reason: format!(
190 "the reviewer classified the call as {} risk, which Auto-Review cannot run automatically",
191 risk.as_str()
192 ),
193 },
194 Some(ReviewerVerdict {
195 action: AutoReviewAction::Block,
196 risk,
197 reason,
198 }) => ReviewerOutcome::Deny { risk, reason },
199 Some(_) | None => ReviewerOutcome::Unavailable {
200 reason: format!("the reviewer answer was unparseable ({} chars)", text.len()),
201 },
202 }
203 }
204
205 #[cfg(test)]
206 mod tests {
207 use super::*;
208 use crate::llm_client::mock::MockLlmClient;
209
210 fn response(text: &str, usage: Usage) -> MessageResponse {
211 MessageResponse {
212 id: "review".to_string(),
213 r#type: "message".to_string(),
214 role: "assistant".to_string(),
215 content: vec![ContentBlock::Text {
216 text: text.to_string(),
217 cache_control: None,
218 }],
219 model: "mock-model".to_string(),
220 stop_reason: Some("end_turn".to_string()),
221 stop_sequence: None,
222 container: None,
223 usage,
224 }
225 }
226
227 #[tokio::test]
228 async fn guardian_rechecks_identical_calls_instead_of_reusing_a_cached_allow() {
229 use wiremock::matchers::method;
230 use wiremock::{Mock, MockServer, ResponseTemplate};
231
232 let server = MockServer::start().await;
233 let client = crate::client::CodewhaleClient::new(&crate::config::Config {
234 api_key: Some("test-guardian-cache-key".to_string()),
235 base_url: Some(server.uri()),
236 ..Default::default()
237 })
238 .unwrap();
239 for (decision, risk) in [("allow", "low"), ("deny", "high")] {
240 server.reset().await;
241 let verdict = serde_json::json!({
242 "decision": decision,
243 "risk_level": risk,
244 "reason": "current authorization evidence",
245 });
246 Mock::given(method("POST"))
247 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
248 "id": "guardian-fresh",
249 "object": "chat.completion",
250 "model": "deepseek-v4-pro",
251 "choices": [{"index": 0, "message": {
252 "role": "assistant", "content": verdict.to_string(),
253 }, "finish_reason": "stop"}],
254 "usage": {"prompt_tokens": 9, "completion_tokens": 3, "total_tokens": 12},
255 })))
256 .mount(&server)
257 .await;
258 let result = consult_reviewer(
259 &client,
260 "the same proposed call under current policy",
261 &CancellationToken::new(),
262 )
263 .await;
264 assert_eq!(result.outcome.audit_decision(), decision);
265 assert_eq!(result.usage.unwrap().output_tokens, 3);
266 assert_eq!(server.received_requests().await.unwrap().len(), 1);
267 }
268 }
269
270 #[tokio::test]
271 async fn reviewer_records_usage_and_keeps_context_untrusted() {
272 let usage = Usage {
273 input_tokens: 17,
274 output_tokens: 9,
275 ..Usage::default()
276 };
277 let mock = MockLlmClient::new(Vec::new());
278 mock.push_message_response(response(
279 r#"{"risk_level":"low","decision":"allow","reason":"bounded and authorized"}"#,
280 usage.clone(),
281 ));
282
283 let result = consult_reviewer(
284 &mock,
285 r#"{"proposed_tool_call":{"tool":"exec_shell"}}"#,
286 &CancellationToken::new(),
287 )
288 .await;
289
290 assert_eq!(
291 result.outcome,
292 ReviewerOutcome::Allow {
293 risk: ReviewerRiskLevel::Low,
294 reason: "bounded and authorized".to_string()
295 }
296 );
297 assert_eq!(result.usage, Some(usage));
298 let request = mock.last_request().expect("reviewer request");
299 assert_eq!(request.tools, None, "guardian requests never expose tools");
300 let SystemPrompt::Text(system) = request.system.expect("guardian policy") else {
301 panic!("guardian system prompt must be text");
302 };
303 assert!(system.contains("Never infer user intent"));
304 assert!(!system.contains("Prefer reversible work."));
305 }
306
307 #[tokio::test]
308 async fn reviewer_distinguishes_denial_malformed_and_incomplete_answers() {
309 let deny = MockLlmClient::new(Vec::new());
310 deny.push_message_response(response(
311 r#"{"risk_level":"high","decision":"deny","reason":"destination is not authorized"}"#,
312 Usage::default(),
313 ));
314 assert_eq!(
315 consult_reviewer(&deny, "context", &CancellationToken::new())
316 .await
317 .outcome,
318 ReviewerOutcome::Deny {
319 risk: ReviewerRiskLevel::High,
320 reason: "destination is not authorized".to_string()
321 }
322 );
323 assert!(matches!(
324 verdict_from_response(&response(
325 r#"{"risk_level":"high","decision":"allow","reason":"the request mentions it"}"#,
326 Usage::default(),
327 )),
328 ReviewerOutcome::Deny {
329 risk: ReviewerRiskLevel::High,
330 ..
331 }
332 ));
333
334 let malformed = MockLlmClient::new(Vec::new());
335 malformed.push_message_response(response("allow it", Usage::default()));
336 let malformed_result =
337 consult_reviewer(&malformed, "context", &CancellationToken::new()).await;
338 assert!(matches!(
339 malformed_result.outcome,
340 ReviewerOutcome::Unavailable { .. }
341 ));
342 assert!(malformed_result.usage.is_some());
343
344 let incomplete = MockLlmClient::new(Vec::new());
345 let mut incomplete_response = response(
346 r#"{"risk_level":"low","decision":"allow","reason":"looks safe"}"#,
347 Usage::default(),
348 );
349 incomplete_response.stop_reason = Some("max_tokens".to_string());
350 incomplete.push_message_response(incomplete_response);
351 assert_eq!(
352 consult_reviewer(&incomplete, "context", &CancellationToken::new())
353 .await
354 .outcome,
355 ReviewerOutcome::Unavailable {
356 reason: "the reviewer answer was incomplete".to_string()
357 }
358 );
359 }
360
361 #[tokio::test]
362 async fn reviewer_cancellation_aborts_without_calling_provider() {
363 let mock = MockLlmClient::new(Vec::new());
364 let cancel = CancellationToken::new();
365 cancel.cancel();
366
367 let result = consult_reviewer(&mock, "context", &cancel).await;
368
369 assert_eq!(result.outcome, ReviewerOutcome::Cancelled);
370 assert!(result.usage.is_none());
371 assert_eq!(mock.call_count(), 0);
372 }
373
374 #[tokio::test]
375 async fn reviewer_denies_oversized_exact_context_without_calling_provider() {
376 let mock = MockLlmClient::new(Vec::new());
377 let context = "x".repeat(MAX_REVIEW_CONTEXT_BYTES + 1);
378
379 let result = consult_reviewer(&mock, &context, &CancellationToken::new()).await;
380
381 assert_eq!(
382 result.outcome,
383 ReviewerOutcome::Unavailable {
384 reason: "the exact review context exceeded the guardian limit".to_string()
385 }
386 );
387 assert!(result.usage.is_none());
388 assert_eq!(mock.call_count(), 0);
389 }
390 }
391
391 lines RUST