diff --git a/Cargo.lock b/Cargo.lock index f01d176d3..117d356bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2525,6 +2525,7 @@ dependencies = [ "async-trait", "futures-util", "prefill-router", + "regex", "reqwest", "serde", "serde_json", diff --git a/crates/switchyard-runner/Cargo.toml b/crates/switchyard-runner/Cargo.toml index a330157f5..27041d7f1 100644 --- a/crates/switchyard-runner/Cargo.toml +++ b/crates/switchyard-runner/Cargo.toml @@ -20,6 +20,7 @@ prefill-router = ["dep:prefill-router"] libsy = { package = "switchyard-libsy", path = "../libsy", version = "0.3.0" } prefill-router = { workspace = true, optional = true } reqwest.workspace = true +regex.workspace = true serde.workspace = true serde_json.workspace = true switchyard-llm-client.workspace = true diff --git a/crates/switchyard-runner/src/config.rs b/crates/switchyard-runner/src/config.rs index 6866afb62..69c8ec678 100644 --- a/crates/switchyard-runner/src/config.rs +++ b/crates/switchyard-runner/src/config.rs @@ -19,7 +19,7 @@ use switchyard_llm_client::{ }; use switchyard_protocol::{Category, ModelId, RoutedDecisionClient, RoutedLlmClient, WireFormat}; -use crate::privacy::PrivacyPolicy; +use crate::privacy::{DeterministicDetector, PrivacyPolicy}; use crate::route::ExecutionLane; use crate::{ AlgorithmSpec, AuxiliaryTarget, CallerAuthKind, DecisionTarget, ModelCapabilities, Route, @@ -85,10 +85,29 @@ struct RouteConfig { struct PrivacyConfig { restricted_targets: BTreeMap, restricted_decision_target: Option, + deterministic: Option, #[serde(default)] accept_external_signal: bool, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeterministicConfig { + #[serde(default)] + detectors: BTreeSet, +} + +impl DeterministicConfig { + fn build(&self, route_name: &str) -> RunnerResult> { + if self.detectors.is_empty() { + return Err(RunnerError::configuration(format!( + "route {route_name} privacy deterministic must configure at least one detector" + ))); + } + Ok(self.detectors.iter().copied().collect()) + } +} + struct TargetPromptPolicy { prompts: HashMap, routing_answer_target: Option, @@ -327,11 +346,16 @@ impl DeploymentConfig { "route {route_name} cannot use privacy with prefill_router" ))); } - if !config.accept_external_signal { + if !config.accept_external_signal && config.deterministic.is_none() { return Err(RunnerError::configuration(format!( "route {route_name} privacy must configure at least one request input" ))); } + let detectors = config + .deterministic + .as_ref() + .map(|config| config.build(route_name)) + .transpose()?; let restricted_targets = self.resolve_lane_targets(route_name, route, Some(&config.restricted_targets))?; let restricted_decision = match ( @@ -366,7 +390,14 @@ impl DeploymentConfig { clients, )?; Ok(Some(BuiltPrivacy { - policy: PrivacyPolicy::new(config.accept_external_signal), + policy: PrivacyPolicy::new(config.accept_external_signal, detectors).map_err( + |error| { + RunnerError::configuration_source( + format!("route {route_name} privacy detectors could not be compiled"), + error, + ) + }, + )?, restricted, })) } @@ -1135,6 +1166,7 @@ bogus = true mod deployment_tests { use super::*; use serde_json::json; + use switchyard_protocol::{Message, Request, Role}; const VALID_CONFIG: &str = r#" schema_version = 1 @@ -1234,6 +1266,19 @@ weak = "weak" ) } + fn deterministic_privacy_config() -> String { + format!( + r#"{VALID_CONFIG} + +[routes.passthrough.privacy.restricted_targets] +weak = "strong" + +[routes.passthrough.privacy.deterministic] +detectors = ["bearer_token"] +"# + ) + } + #[test] fn public_runner_from_toml_builds_a_deployment() -> RunnerResult<()> { let runner = Runner::from_toml(VALID_CONFIG)?; @@ -1303,6 +1348,36 @@ weak = "weak" Ok(()) } + #[tokio::test] + async fn deterministic_privacy_selects_the_restricted_lane() -> RunnerResult<()> { + let configured = deterministic_privacy_config(); + let runner = Runner::from_toml(&configured)?; + let route = runner + .route("switchyard/passthrough") + .expect("privacy route should exist"); + + assert_eq!( + route + .decide(Request::default()) + .await? + .selected_model_id()?, + "weak/model" + ); + let mut request = Request::default(); + request.llm_request.messages.push(Message::text( + Role::User, + "Authorization: Bearer abcdefghijklmnop", + )); + assert_eq!( + route.decide(request).await?.selected_model_id()?, + "strong/model" + ); + + let empty = configured.replace("detectors = [\"bearer_token\"]", "detectors = []"); + assert!(error_message(&empty).contains("must configure at least one detector")); + Ok(()) + } + #[test] fn duplicate_route_ids_are_rejected() { let config = format!( diff --git a/crates/switchyard-runner/src/privacy.rs b/crates/switchyard-runner/src/privacy.rs index aced127ad..016f93998 100644 --- a/crates/switchyard-runner/src/privacy.rs +++ b/crates/switchyard-runner/src/privacy.rs @@ -3,16 +3,21 @@ //! Privacy policy used to select a route's execution lane. +mod deterministic; + use serde_json::Value; use strum_macros::{EnumString, IntoStaticStr}; use switchyard_protocol::{LlmClientError, Request, WireFormat}; const EXTERNAL_RESTRICTION_KEY: &str = "switchyard.internal.external_privacy_restriction"; +pub(crate) use deterministic::DeterministicDetector; +use deterministic::{Assessment, Inspector}; pub(crate) const SELECTED_LANE_KEY: &str = "switchyard.internal.privacy_lane"; const RESPONSES_STATE_FIELDS: [&str; 2] = ["previous_response_id", "conversation"]; pub(crate) struct PrivacyPolicy { accept_external_signal: bool, + inspector: Option, } /// Target set allowed to serve one request. @@ -60,6 +65,7 @@ pub(crate) struct PrivacyDecision { pub(crate) enum PrivacySource { Policy, ExternalSignal, + Deterministic, } impl PrivacySource { @@ -82,28 +88,6 @@ impl PrivacyDecision { } } -impl PrivacyPolicy { - pub(crate) const fn new(accept_external_signal: bool) -> Self { - Self { - accept_external_signal, - } - } - - pub(crate) fn decide(&self, request: &Request) -> Result { - if !has_external_restriction(request) { - return Ok(PrivacyDecision::all_clear()); - } - if !self.accept_external_signal { - return Err(external_signal_not_accepted()); - } - Ok(PrivacyDecision::new( - PrivacyLane::Restricted, - PrivacySource::ExternalSignal, - "restricted", - )) - } -} - /// Marks a request as requiring a route that accepts external privacy signals. /// Execution fails if the selected route has not enabled `accept_external_signal`. /// This marker is available only to trusted in-process hosts, not HTTP clients. @@ -130,6 +114,44 @@ pub(crate) fn external_signal_not_accepted() -> LlmClientError { } } +impl PrivacyPolicy { + pub(crate) fn new( + accept_external_signal: bool, + detectors: Option>, + ) -> Result { + Ok(Self { + accept_external_signal, + inspector: detectors.map(Inspector::new).transpose()?, + }) + } + + pub(crate) fn decide(&self, request: &Request) -> Result { + if has_external_restriction(request) { + if !self.accept_external_signal { + return Err(external_signal_not_accepted()); + } + return Ok(PrivacyDecision::new( + PrivacyLane::Restricted, + PrivacySource::ExternalSignal, + "restricted", + )); + } + let Some(inspector) = &self.inspector else { + return Ok(PrivacyDecision::all_clear()); + }; + Ok(match inspector.inspect(request) { + Assessment::Restricted(reason_code) | Assessment::Indeterminate(reason_code) => { + PrivacyDecision::new( + PrivacyLane::Restricted, + PrivacySource::Deterministic, + reason_code, + ) + } + Assessment::Clear => PrivacyDecision::all_clear(), + }) + } +} + pub(crate) fn validate_mixed_request(request: &Request) -> Result<(), LlmClientError> { let extensions = &request.llm_request.extensions.fields; let preserved = &request.llm_request.preservation.requests; @@ -167,20 +189,61 @@ fn has_responses_state(body: &Value) -> bool { #[cfg(test)] mod tests { use super::*; + use switchyard_protocol::{ContentBlock, Message, Role}; #[test] fn external_restriction_requires_route_opt_in() { let mut request = Request::default(); mark_privacy_restricted(&mut request); - assert!(PrivacyPolicy::new(false).decide(&request).is_err()); + assert!( + PrivacyPolicy::new(false, None) + .expect("empty detector configuration should compile") + .decide(&request) + .is_err() + ); assert!(matches!( - PrivacyPolicy::new(true).decide(&request), + PrivacyPolicy::new(true, None) + .expect("empty detector configuration should compile") + .decide(&request), Ok(PrivacyDecision { lane: PrivacyLane::Restricted, .. }) )); + + let mut opaque = Request::default(); + opaque.llm_request.messages.push(Message { + role: Role::User, + content: vec![ContentBlock::Unknown { + provider: "custom".into(), + raw: Value::Null, + }], + }); + let decision = PrivacyPolicy::new(true, None) + .expect("empty detector configuration should compile") + .decide(&opaque) + .expect("unmarked request should remain valid"); + assert!(matches!(decision.lane, PrivacyLane::Standard)); + } + + #[test] + fn deterministic_inspection_fails_closed_on_opaque_content() { + let mut request = Request::default(); + request.llm_request.messages.push(Message { + role: Role::User, + content: vec![ContentBlock::Unknown { + provider: "custom".into(), + raw: Value::Null, + }], + }); + + let decision = PrivacyPolicy::new(false, Some(vec![DeterministicDetector::Email])) + .expect("static detector patterns should compile") + .decide(&request) + .expect("opaque content should select a lane"); + assert!(matches!(decision.lane, PrivacyLane::Restricted)); + assert_eq!(decision.reason_code, "opaque_content"); } #[test] diff --git a/crates/switchyard-runner/src/privacy/deterministic.rs b/crates/switchyard-runner/src/privacy/deterministic.rs new file mode 100644 index 000000000..e77c5e678 --- /dev/null +++ b/crates/switchyard-runner/src/privacy/deterministic.rs @@ -0,0 +1,518 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded, deterministic request inspection. + +use regex::RegexSet; +use serde::Deserialize; +use strum_macros::IntoStaticStr; +use switchyard_protocol::{ContentBlock, LlmRequest, Request, ToolChoice, WireFormat}; + +const MAX_SCAN_BYTES: usize = 1024 * 1024; +const MAX_SCAN_DEPTH: usize = 64; +const MAX_SCAN_STEPS: usize = 4096; +const STRUCTURAL_SEPARATOR: &str = "\n|\n"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, IntoStaticStr, Ord, PartialEq, PartialOrd)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub(crate) enum DeterministicDetector { + Email, + Phone, + ApiKey, + IpAddress, + Ipv6, + Url, + Uuid, + BearerToken, + Jwt, + CreditCard, + AwsAccessKeyId, + AwsSecretAccessKey, + GcpApiKey, + AzureStorageAccountKey, + NvidiaApiKey, +} + +impl DeterministicDetector { + fn as_str(self) -> &'static str { + self.into() + } + + const fn pattern(self) -> &'static str { + match self { + Self::Email => r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + Self::Phone => r"\+?[0-9][0-9()\-\s]{6,}[0-9]", + Self::ApiKey => r"\b(?:sk|rk|pk|ak)-[A-Za-z0-9_-]{8,}", + Self::IpAddress => r"\b(?:\d{1,3}\.){3}\d{1,3}\b", + Self::Ipv6 => { + r"(?:([A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}|([A-Fa-f0-9]{1,4}:){1,7}:|([A-Fa-f0-9]{1,4}:){1,6}:[A-Fa-f0-9]{1,4}|([A-Fa-f0-9]{1,4}:){1,5}(?::[A-Fa-f0-9]{1,4}){1,2}|([A-Fa-f0-9]{1,4}:){1,4}(?::[A-Fa-f0-9]{1,4}){1,3}|([A-Fa-f0-9]{1,4}:){1,3}(?::[A-Fa-f0-9]{1,4}){1,4}|([A-Fa-f0-9]{1,4}:){1,2}(?::[A-Fa-f0-9]{1,4}){1,5}|[A-Fa-f0-9]{1,4}:(?:(?::[A-Fa-f0-9]{1,4}){1,6})|:(?:(?::[A-Fa-f0-9]{1,4}){1,7}|:))" + } + Self::Url => r"https?://[^\s]+", + Self::Uuid => { + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\b" + } + Self::BearerToken => r"(?i)\bBearer\s+[A-Za-z0-9._~+/\-]{12,}={0,2}\b", + Self::Jwt => r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", + Self::CreditCard => r"\b(?:\d[ -]?){13,19}\b", + Self::AwsAccessKeyId => { + r"\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|APKA|AROA|AUSA)[A-Z0-9]{16}\b" + } + Self::AwsSecretAccessKey => r"\b[A-Za-z0-9/+=]{40}\b", + Self::GcpApiKey => r"\bAIza[0-9A-Za-z\-_]{35}\b", + Self::AzureStorageAccountKey => r"\b[A-Za-z0-9+/]{86}==", + Self::NvidiaApiKey => r"\bnvapi-[A-Za-z0-9_-]{20,}\b", + } + } +} + +#[derive(Debug, PartialEq)] +pub(super) enum Assessment { + Clear, + Restricted(&'static str), + Indeterminate(&'static str), +} + +pub(super) struct Inspector { + patterns: RegexSet, + detectors: Vec, +} + +impl Inspector { + pub(super) fn new(detectors: Vec) -> Result { + let patterns = RegexSet::new( + detectors + .iter() + .copied() + .map(DeterministicDetector::pattern), + )?; + Ok(Self { + patterns, + detectors, + }) + } + + pub(super) fn inspect(&self, request: &Request) -> Assessment { + let mut scan = Scan { + bytes: 0, + steps: 0, + text: String::new(), + }; + match scan.request(&request.llm_request) { + Ok(()) => self + .match_text(&scan.text) + .map(Assessment::Restricted) + .unwrap_or(Assessment::Clear), + Err(assessment) => assessment, + } + } + + fn match_text(&self, text: &str) -> Option<&'static str> { + self.patterns + .matches(text) + .iter() + .next() + .map(|index| self.detectors[index].as_str()) + } +} + +struct Scan { + bytes: usize, + steps: usize, + text: String, +} + +impl Scan { + fn request(&mut self, request: &LlmRequest) -> Result<(), Assessment> { + // Keep this exhaustive so each new protocol field gets an explicit privacy decision. + let LlmRequest { + model: _, + instructions, + messages, + tools, + tool_choice, + sampling: _, + output, + reasoning, + stream: _, + extensions, + preservation, + } = request; + for instruction in instructions { + self.blocks(&instruction.content, 0)?; + } + for message in messages { + self.blocks(&message.content, 0)?; + } + for tool in tools { + self.text(&tool.name)?; + if let Some(description) = &tool.description { + self.text(description)?; + } + self.json(&tool.parameters, 0)?; + } + if let Some(choice) = tool_choice { + match choice { + ToolChoice::Tool { name } => self.text(name)?, + ToolChoice::Raw(value) => self.json(value, 0)?, + ToolChoice::Auto | ToolChoice::Required | ToolChoice::None => {} + } + } + if let Some(format) = &output.response_format { + self.json(format, 0)?; + } + if let Some(effort) = &reasoning.effort { + self.text(effort)?; + } + if let Some(raw) = &reasoning.raw { + self.json(raw, 0)?; + } + for (key, value) in &extensions.fields { + self.text(key)?; + self.json(value, 0)?; + } + // Exact replay may retain fields omitted by normalization, so both + // representations share the same bounded scan budget. + for (format, body) in &preservation.requests { + if ![ + WireFormat::OpenAiChat, + WireFormat::OpenAiResponses, + WireFormat::AnthropicMessages, + ] + .into_iter() + .any(|known| format.as_str() == known.as_str()) + { + return Err(Assessment::Indeterminate("opaque_content")); + } + self.json(body, 0)?; + } + Ok(()) + } + + fn blocks(&mut self, blocks: &[ContentBlock], depth: usize) -> Result<(), Assessment> { + let mut continues_text = false; + for block in blocks { + self.step(depth)?; + match block { + ContentBlock::Text { text } | ContentBlock::Refusal { text } => { + self.text_fragment(text, continues_text)?; + continues_text = true; + } + ContentBlock::Reasoning { + text, + signature, + details, + } => { + continues_text = false; + self.text(text)?; + if let Some(signature) = signature { + self.text(signature)?; + } + for detail in details { + self.json(detail, depth + 1)?; + } + } + ContentBlock::ToolCall(call) => { + continues_text = false; + self.text(&call.id)?; + self.text(&call.name)?; + self.json(&call.arguments, depth + 1)?; + } + ContentBlock::ToolResult(result) => { + continues_text = false; + self.text(&result.tool_call_id)?; + self.blocks(&result.content, depth + 1)?; + } + ContentBlock::Image { .. } + | ContentBlock::Audio { .. } + | ContentBlock::Video { .. } + | ContentBlock::File { .. } + | ContentBlock::Unknown { .. } => { + return Err(Assessment::Indeterminate("opaque_content")); + } + } + } + Ok(()) + } + + fn json(&mut self, value: &serde_json::Value, depth: usize) -> Result<(), Assessment> { + self.step(depth)?; + match value { + serde_json::Value::String(value) => self.text(value), + serde_json::Value::Array(values) => { + for value in values { + self.json(value, depth + 1)?; + } + Ok(()) + } + serde_json::Value::Object(values) => { + let kind = values.get("type").and_then(serde_json::Value::as_str); + let encrypted_reasoning = kind == Some("reasoning.encrypted") + || kind == Some("reasoning") + && values + .get("encrypted_content") + .is_some_and(|value| !value.is_null()); + if encrypted_reasoning { + return Err(Assessment::Indeterminate("opaque_content")); + } + for (key, value) in values { + self.text(key)?; + self.json(value, depth + 1)?; + } + Ok(()) + } + serde_json::Value::Number(value) => self.text(&value.to_string()), + serde_json::Value::Null | serde_json::Value::Bool(_) => Ok(()), + } + } + + fn text(&mut self, text: &str) -> Result<(), Assessment> { + self.text_fragment(text, false) + } + + fn text_fragment(&mut self, text: &str, continues_text: bool) -> Result<(), Assessment> { + self.step(0)?; + self.bytes = self.bytes.saturating_add(text.len()); + if self.bytes > MAX_SCAN_BYTES { + return Err(Assessment::Indeterminate("scan_limit")); + } + if !continues_text && !self.text.is_empty() { + // Keep unrelated values from forming one match across a structural boundary. + self.text.push_str(STRUCTURAL_SEPARATOR); + } + // Adjacent text blocks stay contiguous so splitting one value cannot bypass a rule. + self.text.push_str(text); + Ok(()) + } + + fn step(&mut self, depth: usize) -> Result<(), Assessment> { + if depth > MAX_SCAN_DEPTH { + return Err(Assessment::Indeterminate("scan_limit")); + } + self.steps = self.steps.saturating_add(1); + if self.steps > MAX_SCAN_STEPS { + return Err(Assessment::Indeterminate("scan_limit")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use switchyard_protocol::{ContentBlock, Message, Role, ToolCall}; + + use super::*; + + fn inspect_with(detectors: &[DeterministicDetector], text: &str) -> Assessment { + let mut request = Request::default(); + request + .llm_request + .messages + .push(Message::text(Role::User, text)); + Inspector::new(detectors.to_vec()) + .expect("static patterns should compile") + .inspect(&request) + } + + #[test] + fn supports_relay_detector_catalog_and_switchyard_credentials() { + for (detector, text) in [ + (DeterministicDetector::Email, "user@company.test"), + (DeterministicDetector::Phone, "+1 (555) 123-4567"), + (DeterministicDetector::ApiKey, "sk-abcdefgh"), + (DeterministicDetector::IpAddress, "192.168.1.1"), + ( + DeterministicDetector::Ipv6, + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + ), + (DeterministicDetector::Url, "https://example.test/path"), + ( + DeterministicDetector::Uuid, + "550e8400-e29b-41d4-a716-446655440000", + ), + ( + DeterministicDetector::BearerToken, + "Bearer abcdefghijklmnop", + ), + (DeterministicDetector::Jwt, "eyJheader.payload.signature"), + (DeterministicDetector::CreditCard, "4111 1111 1111 1111"), + ( + DeterministicDetector::AwsAccessKeyId, + "AKIAIOSFODNN7EXAMPLE", + ), + ( + DeterministicDetector::AwsSecretAccessKey, + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ), + ( + DeterministicDetector::GcpApiKey, + "AIza01234567890123456789012345678901234", + ), + ( + DeterministicDetector::AzureStorageAccountKey, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + ), + ( + DeterministicDetector::NvidiaApiKey, + "nvapi-abcdefghijklmnopqrstuvwxyz", + ), + ] { + assert_eq!( + inspect_with(&[detector], text), + Assessment::Restricted(detector.as_str()), + "{}", + detector.as_str() + ); + } + + assert_eq!( + inspect_with(&[DeterministicDetector::Email], "Bearer abcdefghijklmnop"), + Assessment::Clear + ); + assert_eq!( + inspect_with(&[DeterministicDetector::ApiKey], "task-abcdefgh"), + Assessment::Clear + ); + } + + #[test] + fn inspects_structured_and_preserved_request_content() { + let inspector = Inspector::new(vec![ + DeterministicDetector::Email, + DeterministicDetector::BearerToken, + DeterministicDetector::CreditCard, + ]) + .expect("static patterns should compile"); + let mut request = Request::default(); + request.llm_request.messages.push(Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolCall(ToolCall { + id: "call-1".to_string(), + name: "lookup".to_string(), + arguments: json!({"card": 4111111111111111_u64}), + })], + }); + assert_eq!( + inspector.inspect(&request), + Assessment::Restricted("credit_card") + ); + + let mut request = Request::default(); + request.llm_request.messages.push(Message { + role: Role::User, + content: vec![ + ContentBlock::Text { + text: "user@".to_string(), + }, + ContentBlock::Text { + text: "company.test".to_string(), + }, + ], + }); + assert_eq!(inspector.inspect(&request), Assessment::Restricted("email")); + + let mut request = Request::default(); + request.llm_request.preservation.requests.insert( + "openai_chat".into(), + json!({"token": "Bearer abcdefghijklmnop"}), + ); + assert_eq!( + inspector.inspect(&request), + Assessment::Restricted("bearer_token") + ); + } + + #[test] + fn does_not_join_unrelated_values_into_one_match() { + let inspector = Inspector::new(vec![DeterministicDetector::CreditCard]) + .expect("static patterns should compile"); + let mut request = Request::default(); + request + .llm_request + .messages + .push(Message::text(Role::User, "4111 1111")); + request + .llm_request + .messages + .push(Message::text(Role::User, "1111 1111")); + + assert_eq!(inspector.inspect(&request), Assessment::Clear); + } + + #[test] + fn opaque_or_oversized_content_is_indeterminate() { + let inspector = Inspector::new(vec![DeterministicDetector::Email]) + .expect("static patterns should compile"); + let mut opaque = Request::default(); + opaque.llm_request.messages.push(Message { + role: Role::User, + content: vec![ContentBlock::Unknown { + provider: "custom".into(), + raw: json!({}), + }], + }); + assert_eq!( + inspector.inspect(&opaque), + Assessment::Indeterminate("opaque_content") + ); + let mut encrypted = Request::default(); + encrypted.llm_request.messages.push(Message { + role: Role::Assistant, + content: vec![ContentBlock::Reasoning { + text: String::new(), + signature: None, + details: vec![json!({ + "type": "reasoning", + "encrypted_content": "opaque" + })], + }], + }); + assert_eq!( + inspector.inspect(&encrypted), + Assessment::Indeterminate("opaque_content") + ); + + let mut custom = Request::default(); + custom + .llm_request + .preservation + .requests + .insert("custom".into(), json!({})); + assert_eq!( + inspector.inspect(&custom), + Assessment::Indeterminate("opaque_content") + ); + + let oversized = Request { + llm_request: LlmRequest { + messages: vec![Message::text(Role::User, "x".repeat(MAX_SCAN_BYTES + 1))], + ..LlmRequest::default() + }, + ..Request::default() + }; + let mut deep_value = serde_json::Value::Null; + for _ in 0..=MAX_SCAN_DEPTH { + deep_value = json!([deep_value]); + } + let mut too_deep = Request::default(); + too_deep + .llm_request + .extensions + .fields + .insert("deep".to_string(), deep_value); + + let mut too_many = Request::default(); + too_many.llm_request.extensions.fields.insert( + "many".to_string(), + serde_json::Value::Array(vec![serde_json::Value::Null; MAX_SCAN_STEPS]), + ); + + for request in [&oversized, &too_deep, &too_many] { + assert_eq!( + inspector.inspect(request), + Assessment::Indeterminate("scan_limit") + ); + } + } +} diff --git a/crates/switchyard-runner/src/route.rs b/crates/switchyard-runner/src/route.rs index f2f29dfd5..6c9338802 100644 --- a/crates/switchyard-runner/src/route.rs +++ b/crates/switchyard-runner/src/route.rs @@ -470,8 +470,10 @@ mod tests { ])), "restricted-auxiliary", ); - let route = Route::from_lane(standard, None, ModelCapabilities::default()) - .with_privacy(PrivacyPolicy::new(true), restricted); + let route = Route::from_lane(standard, None, ModelCapabilities::default()).with_privacy( + PrivacyPolicy::new(true, None).expect("empty detector configuration should compile"), + restricted, + ); let output = route .execute(restricted_request(), None)