From 80c1197583cc9dc909b7e010c78b4ad58c81e8ce Mon Sep 17 00:00:00 2001 From: dorien-koelemeijer <62866702+dorien-koelemeijer@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:26:44 +0000 Subject: [PATCH] fix(security): treat an unavailable prompt classifier as no signal (#12452) Co-authored-by: Claude Opus 5 --- crates/goose/src/security/scanner.rs | 84 ++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/goose/src/security/scanner.rs b/crates/goose/src/security/scanner.rs index fe47564e7a..db9d364487 100644 --- a/crates/goose/src/security/scanner.rs +++ b/crates/goose/src/security/scanner.rs @@ -241,14 +241,17 @@ impl PromptInjectionScanner { }); } - let max_confidence = stream::iter(user_messages) + let (max_confidence, any_scanned) = stream::iter(user_messages) .map(|msg| async move { self.scan_with_classifier(&msg, classifier, ClassifierType::Prompt) .await }) .buffer_unordered(ML_SCAN_CONCURRENCY) - .fold(0.0_f32, |acc, result| async move { - result.unwrap_or(0.0).max(acc) + .fold((0.0_f32, false), |(acc, scanned), result| async move { + match result { + Some(confidence) => (confidence.max(acc), true), + None => (acc, scanned), + } }) .await; @@ -256,7 +259,7 @@ impl PromptInjectionScanner { confidence: max_confidence, pattern_confidence: 0.0, pattern_matches: Vec::new(), - ml_confidence: Some(max_confidence), + ml_confidence: any_scanned.then_some(max_confidence), used_pattern_detection: false, }) } @@ -432,7 +435,7 @@ impl Default for PromptInjectionScanner { mod tests { use super::*; use rmcp::object; - use wiremock::matchers::method; + use wiremock::matchers::{body_string_contains, method}; use wiremock::{Mock, MockServer, ResponseTemplate}; async fn classifier_with_confidence( @@ -715,4 +718,75 @@ mod tests { vec!["newest prompt", "middle prompt", "oldest prompt"] ); } + + async fn failing_prompt_classifier() -> (MockServer, ClassificationClient) { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock_server) + .await; + let classifier = + ClassificationClient::from_endpoint(mock_server.uri(), None, None).unwrap(); + (mock_server, classifier) + } + + #[tokio::test] + async fn failed_prompt_scan_does_not_lower_command_confidence() { + let (_command_server, command_classifier) = classifier_with_confidence(0.85).await; + let (_prompt_server, prompt_classifier) = failing_prompt_classifier().await; + let scanner = PromptInjectionScanner { + pattern_matcher: PatternMatcher::new(), + command_classifier: Some(command_classifier), + prompt_classifier: Some(prompt_classifier), + }; + + let tool_call = + CallToolRequestParams::new("shell").with_arguments(object!({"command": "echo hello"})); + let messages = vec![Message::user().with_text("deploy the service")]; + + let result = scanner + .analyze_tool_call_with_context(&tool_call, &messages) + .await + .unwrap(); + + assert!((result.confidence - 0.85).abs() < 1e-6); + } + + #[tokio::test] + async fn partially_failed_prompt_scan_keeps_successful_scores() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_string_contains("unscannable")) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock_server) + .await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([[{ + "label": "INJECTION", + "score": 0.6 + }, { + "label": "SAFE", + "score": 0.4 + }]])), + ) + .mount(&mock_server) + .await; + let classifier = + ClassificationClient::from_endpoint(mock_server.uri(), None, None).unwrap(); + let scanner = PromptInjectionScanner { + pattern_matcher: PatternMatcher::new(), + command_classifier: None, + prompt_classifier: Some(classifier), + }; + let messages = vec![ + Message::user().with_text("unscannable message"), + Message::user().with_text("ordinary message"), + ]; + + let result = scanner.scan_conversation(&messages).await.unwrap(); + + let confidence = result.ml_confidence.expect("surviving scan should report"); + assert!((confidence - 0.6).abs() < 1e-6); + } }