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