1use std::collections::BTreeMap;
7use std::future::Future;
8use std::io::{self, Write};
9use std::sync::Arc;
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value, json};
13use switchyard_protocol::{
14 Category, ChoiceOption, DecisionKind, DecisionQuestion, DecisionRequest, DecisionResponse,
15 DecisionValue, FormatId, InstructionBlock, LlmRequest, LlmResponse, Message, ModelId,
16 OutputParams, PreservationMetadata, ProviderExtensions, ReasoningParams, Request, Role, Tool,
17 ToolChoice,
18};
19
20use crate::algorithms::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
21use crate::algorithms::util::llm_judge::{JudgeRuntimeConfig, SerdeDecoder, StructuredJudge};
22use crate::{Call, ClassifierResponseFormat, Driver, Result, RuntimeModels, Step};
23
24const MAX_CONTEXT_BYTES: usize = 64 * 1024;
25const QUESTION_ID: &str = "privacy";
26const PROBABILITY_SUM_TOLERANCE: f64 = 1e-6;
27const DEFAULT_PROMPT: &str = include_str!("prompts/privacy-classifier/prompt.md");
28
29pub struct PrivacyAssessment {
31 reason_code: &'static str,
32 clear_score: Option<f64>,
33 has_sensitive_content: bool,
34}
35
36impl PrivacyAssessment {
37 pub const fn reason_code(&self) -> &'static str {
39 self.reason_code
40 }
41
42 pub const fn clear_score(&self) -> Option<f64> {
44 self.clear_score
45 }
46
47 pub const fn has_sensitive_content(&self) -> bool {
49 self.has_sensitive_content
50 }
51
52 fn failure(reason_code: &'static str) -> Self {
53 Self {
54 reason_code,
55 clear_score: None,
56 has_sensitive_content: false,
57 }
58 }
59}
60
61pub struct PrivacyPreflight {
65 target: ModelId,
66 models: Arc<RuntimeModels>,
67 backend: Backend,
68}
69
70enum Backend {
71 Decision(DecisionQuestion),
72 Llm(StructuredJudge<(), SerdeDecoder<LlmVerdict>>),
73}
74
75impl PrivacyPreflight {
76 pub fn decision(target: ModelId, instructions: Option<&Value>) -> Self {
78 let question = DecisionQuestion {
79 instructions: instructions
80 .cloned()
81 .unwrap_or_else(|| Value::String(DEFAULT_PROMPT.trim().into())),
82 kind: DecisionKind::Choice {
83 options: PrivacyVerdict::ALL
84 .into_iter()
85 .map(|verdict| ChoiceOption {
86 id: verdict.as_str().into(),
87 description: Some(Value::String(verdict.description().into())),
88 })
89 .collect(),
90 },
91 };
92 Self::new(target, Backend::Decision(question))
93 }
94
95 pub fn llm(
98 target: ModelId,
99 prompt: Option<&str>,
100 format: ClassifierResponseFormat,
101 ) -> Result<Self> {
102 if prompt.is_some_and(|prompt| prompt.trim().is_empty()) {
103 return Err(crate::LibsyError::AlgorithmError {
104 message: "privacy classifier prompt must not be empty".into(),
105 });
106 }
107 let prompt = prompt.map(str::to_owned).unwrap_or_else(|| {
108 format!(
109 "{}\n\n{}",
110 DEFAULT_PROMPT.trim(),
111 include_str!("prompts/privacy-classifier/llm.md").trim()
112 )
113 });
114 let schema = verdict_schema();
115 let prompt = match format {
117 ClassifierResponseFormat::JsonSchema => format!(
118 "{prompt}\n\nReturn exactly one JSON object matching this JSON Schema:\n{schema:#}"
119 ),
120 ClassifierResponseFormat::JsonObject => prompt,
121 };
122 let contract = ClassifierContract::from_config(
123 &ClassifierContractConfig::default()
124 .with_prompt(prompt)
125 .with_response_format_type(format),
126 DEFAULT_PROMPT,
127 &json!({
128 "type": "json_schema",
129 "json_schema": {
130 "name": "switchyard_privacy_verdict",
131 "strict": true,
132 "schema": schema,
133 }
134 })
135 .to_string(),
136 )?;
137 let judge = StructuredJudge::new(
138 (),
139 contract,
140 SerdeDecoder::new(),
141 JudgeRuntimeConfig::new(512)?,
142 );
143 Ok(Self::new(target, Backend::Llm(judge)))
144 }
145
146 fn new(target: ModelId, backend: Backend) -> Self {
147 let models = RuntimeModels::new([(Category::Judge, vec![target.clone()])].into())
148 .with_target_restriction();
149 Self {
150 target,
151 models: Arc::new(models),
152 backend,
153 }
154 }
155
156 pub async fn assess<F, Fut>(&self, request: &Request, serve: F) -> PrivacyAssessment
160 where
161 F: FnOnce(Call) -> Fut + Send,
162 Fut: Future<Output = Result<()>> + Send,
163 {
164 let context = match classifier_context(&request.llm_request) {
165 Ok(context) => context,
166 Err(()) => return PrivacyAssessment::failure("input_unavailable"),
167 };
168 let (driver, mut steps) = Driver::new("privacy_classifier", Arc::clone(&self.models));
169 let handled = async {
171 let call = match steps.recv().await {
172 Some(Ok(Step::CallModel(call))) => Call::Model(call),
173 Some(Ok(Step::CallDecision(call))) => Call::Decision(call),
174 _ => return Err("classifier_failed"),
175 };
176 serve(call).await.map_err(|_| "classifier_failed")
177 };
178 tokio::try_join!(self.verdict(context, &driver), handled)
179 .map(|(assessment, ())| assessment)
180 .unwrap_or_else(PrivacyAssessment::failure)
181 }
182
183 async fn verdict(
184 &self,
185 context: BoundedJson,
186 driver: &Driver,
187 ) -> std::result::Result<PrivacyAssessment, &'static str> {
188 let (selected, clear_score) = match &self.backend {
189 Backend::Decision(question) => {
190 let request = DecisionRequest {
191 model: None,
192 context: context.into_value().map_err(|()| "input_unavailable")?,
193 questions: BTreeMap::from([(QUESTION_ID.into(), question.clone())]),
194 };
195 let response = driver
196 .call_decision(request, self.target.clone())
197 .await
198 .map_err(|_| "classifier_failed")?;
199 decision_verdict(&response).ok_or("invalid_verdict")?
200 }
201 Backend::Llm(judge) => {
202 let mut request = judge.build_request_with_messages(vec![Message::text(
203 Role::User,
204 context.into_string().map_err(|()| "input_unavailable")?,
205 )]);
206 request
207 .llm_request
208 .extensions
209 .fields
210 .insert("store".into(), Value::Bool(false));
211 let response = driver
212 .call_model_with_error_recovery(request, vec![self.target.clone()], true)
213 .await
214 .map_err(|_| "classifier_failed")?;
215 let LlmResponse::Agg(response) = response.llm_response else {
216 return Err("invalid_verdict");
217 };
218 let verdict = judge.decode(&response).map_err(|_| "invalid_verdict")?;
219 (verdict.reason_code, verdict.clear_score)
220 }
221 };
222 if !clear_score.is_finite() || !(0.0..=1.0).contains(&clear_score) {
223 return Err("invalid_verdict");
224 }
225 Ok(PrivacyAssessment {
226 reason_code: selected.as_str(),
227 clear_score: Some(clear_score),
228 has_sensitive_content: matches!(
229 selected,
230 PrivacyVerdict::PersonalData
231 | PrivacyVerdict::Credentials
232 | PrivacyVerdict::ConfidentialData
233 | PrivacyVerdict::RegulatedData
234 ),
235 })
236 }
237}
238
239#[derive(Clone, Copy, Deserialize)]
240#[serde(rename_all = "snake_case")]
241enum PrivacyVerdict {
242 NoSensitiveContent,
243 PersonalData,
244 Credentials,
245 ConfidentialData,
246 RegulatedData,
247 Uncertain,
248}
249
250impl PrivacyVerdict {
251 const ALL: [Self; 6] = [
252 Self::NoSensitiveContent,
253 Self::PersonalData,
254 Self::Credentials,
255 Self::ConfidentialData,
256 Self::RegulatedData,
257 Self::Uncertain,
258 ];
259
260 const fn as_str(self) -> &'static str {
261 match self {
262 Self::NoSensitiveContent => "no_sensitive_content",
263 Self::PersonalData => "personal_data",
264 Self::Credentials => "credentials",
265 Self::ConfidentialData => "confidential_data",
266 Self::RegulatedData => "regulated_data",
267 Self::Uncertain => "uncertain",
268 }
269 }
270
271 const fn description(self) -> &'static str {
272 match self {
273 Self::NoSensitiveContent => "No personal, secret, confidential, or regulated content.",
274 Self::PersonalData => "Personal or identifying data is present.",
275 Self::Credentials => "Credentials, secrets, or authentication material are present.",
276 Self::ConfidentialData => "Confidential organizational data is present.",
277 Self::RegulatedData => "Regulated data is present.",
278 Self::Uncertain => "The privacy boundary cannot be determined confidently.",
279 }
280 }
281}
282
283#[derive(Deserialize)]
284#[serde(deny_unknown_fields)]
285struct LlmVerdict {
286 clear_score: f64,
287 reason_code: PrivacyVerdict,
288}
289
290fn verdict_schema() -> Value {
291 json!({
292 "type": "object", "additionalProperties": false,
293 "properties": {
294 "clear_score": {"type": "number", "minimum": 0, "maximum": 1},
295 "reason_code": {"type": "string", "enum": PrivacyVerdict::ALL.map(PrivacyVerdict::as_str)}
296 },
297 "required": ["clear_score", "reason_code"]
298 })
299}
300
301fn decision_verdict(response: &DecisionResponse) -> Option<(PrivacyVerdict, f64)> {
302 let DecisionValue::Choice {
303 selected,
304 probabilities: Some(probabilities),
305 } = &response.answers.get(QUESTION_ID)?.value
306 else {
307 return None;
308 };
309 let selected = PrivacyVerdict::deserialize(serde::de::value::StrDeserializer::<
310 serde::de::value::Error,
311 >::new(selected))
312 .ok()?;
313 let valid_distribution = probabilities.len() == PrivacyVerdict::ALL.len()
314 && PrivacyVerdict::ALL.iter().all(|verdict| {
315 probabilities
316 .get(verdict.as_str())
317 .is_some_and(|p| p.0.is_finite() && (0.0..=1.0).contains(&p.0))
318 })
319 && (probabilities.values().map(|p| p.0).sum::<f64>() - 1.0).abs()
320 <= PROBABILITY_SUM_TOLERANCE;
321 if !valid_distribution {
322 return None;
323 }
324 Some((selected, probabilities.get("no_sensitive_content")?.0))
325}
326
327#[derive(Serialize)]
329struct PrivacyContext<'a> {
330 instructions: &'a [InstructionBlock],
331 messages: &'a [Message],
332 tools: &'a [Tool],
333 tool_choice: Option<&'a ToolChoice>,
334 response_format: Option<&'a Value>,
335 reasoning_effort: Option<&'a str>,
336 reasoning_raw: Option<&'a Value>,
337 extensions: &'a Map<String, Value>,
338 preserved_requests: &'a BTreeMap<FormatId, Value>,
339}
340
341fn classifier_context(request: &LlmRequest) -> std::result::Result<BoundedJson, ()> {
342 let LlmRequest {
344 model: _,
345 instructions,
346 messages,
347 tools,
348 tool_choice,
349 sampling: _,
350 output,
351 reasoning,
352 stream: _,
353 extensions,
354 preservation,
355 } = request;
356 let OutputParams {
357 max_output_tokens: _,
358 response_format,
359 is_schema_enforced: _,
360 } = output;
361 let ReasoningParams { effort, raw } = reasoning;
362 let ProviderExtensions { fields: extensions } = extensions;
363 let PreservationMetadata {
364 requests: preserved_requests,
365 responses: _,
366 } = preservation;
367 if tools.iter().any(|tool| !matches!(tool, Tool::Function(_)))
368 || has_remote_tools(extensions)
369 || preserved_requests
370 .values()
371 .filter_map(Value::as_object)
372 .any(has_remote_tools)
373 {
374 return Err(());
375 }
376 let context = PrivacyContext {
377 instructions,
378 messages,
379 tools,
380 tool_choice: tool_choice.as_ref(),
381 response_format: response_format.as_ref(),
382 reasoning_effort: effort.as_deref(),
383 reasoning_raw: raw.as_ref(),
384 extensions,
385 preserved_requests,
386 };
387 let mut json = BoundedJson::default();
388 serde_json::to_writer(&mut json, &context).map_err(|_| ())?;
389 Ok(json)
390}
391
392fn has_remote_tools(fields: &Map<String, Value>) -> bool {
394 fields.contains_key("mcp_servers")
395 || ["tools", "switchyard_codex_additional_tools"]
396 .into_iter()
397 .any(|key| has_remote_tool(fields.get(key), 0))
398 || fields
399 .get("input")
400 .and_then(Value::as_array)
401 .is_some_and(|items| {
402 items.iter().any(|item| {
403 item.get("type").and_then(Value::as_str) == Some("additional_tools")
404 && has_remote_tool(item.get("tools"), 0)
405 })
406 })
407}
408
409fn has_remote_tool(tools: Option<&Value>, depth: usize) -> bool {
410 tools.and_then(Value::as_array).is_some_and(|tools| {
411 tools
412 .iter()
413 .any(|tool| match tool.get("type").and_then(Value::as_str) {
414 Some("mcp" | "mcp_toolset") => true,
415 Some("namespace") => depth >= 64 || has_remote_tool(tool.get("tools"), depth + 1),
416 _ => false,
417 })
418 })
419}
420
421#[derive(Default)]
422struct BoundedJson(Vec<u8>);
423
424impl BoundedJson {
425 fn into_value(self) -> std::result::Result<Value, ()> {
426 serde_json::from_slice(&self.0).map_err(|_| ())
427 }
428
429 fn into_string(self) -> std::result::Result<String, ()> {
430 String::from_utf8(self.0).map_err(|_| ())
431 }
432}
433
434impl Write for BoundedJson {
435 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
436 if bytes.len() > MAX_CONTEXT_BYTES.saturating_sub(self.0.len()) {
437 return Err(io::Error::other("privacy classifier context exceeds limit"));
438 }
439 self.0.extend_from_slice(bytes);
440 Ok(bytes.len())
441 }
442
443 fn flush(&mut self) -> io::Result<()> {
444 Ok(())
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use std::sync::atomic::{AtomicBool, Ordering};
451
452 use switchyard_protocol::{
453 DecisionAnswer, LlmClientError, Metadata, Probability, Response, ToolDefinition, Usage,
454 text_response,
455 };
456 use tokio::sync::Notify;
457
458 use super::*;
459 use crate::LibsyError;
460
461 fn preflights() -> [PrivacyPreflight; 3] {
462 [
463 PrivacyPreflight::decision("private/classifier".into(), None),
464 PrivacyPreflight::llm(
465 "private/classifier".into(),
466 None,
467 ClassifierResponseFormat::JsonSchema,
468 )
469 .expect("schema judge"),
470 PrivacyPreflight::llm(
471 "private/classifier".into(),
472 None,
473 ClassifierResponseFormat::JsonObject,
474 )
475 .expect("object judge"),
476 ]
477 }
478
479 async fn reply(call: Call, reason: &str, score: f64) -> Result<()> {
480 let failed = || {
481 LibsyError::client_call(
482 "private/classifier",
483 LlmClientError::Configuration {
484 message: "private provider diagnostic".into(),
485 },
486 )
487 };
488 match call {
489 Call::Model(call) => {
490 assert!(call.recover_errors);
491 if reason == "failed" {
492 return call.respond(std::future::ready(Err(failed()))).await;
493 }
494 let content = if reason == "malformed" {
495 "not JSON".into()
496 } else {
497 json!({"reason_code": reason, "clear_score": score}).to_string()
498 };
499 call.respond(std::future::ready(Ok(Response {
500 llm_response: LlmResponse::Agg(text_response(None, content)),
501 metadata: None,
502 upstream_headers: Default::default(),
503 })))
504 .await
505 }
506 Call::Decision(call) => {
507 if reason == "failed" {
508 return call.respond(std::future::ready(Err(failed()))).await;
509 }
510 call.respond(std::future::ready(Ok(decision_response(reason, score))))
511 .await
512 }
513 }
514 }
515
516 fn decision_response(reason: &str, score: f64) -> DecisionResponse {
517 let remaining = if reason == "no_sensitive_content" {
518 "uncertain"
519 } else {
520 reason
521 };
522 let probabilities = PrivacyVerdict::ALL
523 .map(|verdict| {
524 let probability = match verdict.as_str() {
525 "no_sensitive_content" => score,
526 value if value == remaining => 1.0 - score,
527 _ => 0.0,
528 };
529 (verdict.as_str().into(), Probability(probability))
530 })
531 .into();
532 DecisionResponse {
533 id: None,
534 model: None,
535 answers: BTreeMap::from([(
536 QUESTION_ID.into(),
537 DecisionAnswer {
538 value: DecisionValue::Choice {
539 selected: reason.into(),
540 probabilities: Some(probabilities),
541 },
542 provider_confidence: None,
543 },
544 )]),
545 usage: Usage::default(),
546 }
547 }
548
549 #[tokio::test]
551 async fn returns_only_valid_evidence_across_backends() {
552 for preflight in preflights() {
553 for (reason, score, expected, sensitive) in [
554 ("no_sensitive_content", 0.95, "no_sensitive_content", false),
555 ("personal_data", 0.1, "personal_data", true),
556 ("uncertain", 0.5, "uncertain", false),
557 ("malformed", 0.0, "invalid_verdict", false),
558 ("no_sensitive_content", 1.1, "invalid_verdict", false),
559 ("failed", 0.0, "classifier_failed", false),
560 ] {
561 let assessment = preflight
562 .assess(&Request::default(), |call| async move {
563 reply(call, reason, score).await
564 })
565 .await;
566 assert_eq!(assessment.reason_code(), expected);
567 assert_eq!(assessment.has_sensitive_content(), sensitive);
568 assert_eq!(
569 assessment.clear_score(),
570 (!matches!(expected, "invalid_verdict" | "classifier_failed")).then_some(score)
571 );
572 }
573 let assessment = preflight
574 .assess(&Request::default(), |call| async move {
575 reply(call, "no_sensitive_content", 0.95).await?;
576 Err(LibsyError::NoTargets)
577 })
578 .await;
579 assert_eq!(assessment.reason_code(), "classifier_failed");
580 }
581 for invalid in ["missing", "incomplete", "unnormalized"] {
582 let mut response = decision_response("no_sensitive_content", 0.95);
583 let DecisionValue::Choice { probabilities, .. } = &mut response
584 .answers
585 .get_mut(QUESTION_ID)
586 .expect("privacy answer")
587 .value
588 else {
589 panic!("expected choice");
590 };
591 match invalid {
592 "missing" => *probabilities = None,
593 "incomplete" => {
594 probabilities
595 .as_mut()
596 .expect("distribution")
597 .remove("uncertain");
598 }
599 _ => {
600 probabilities
601 .as_mut()
602 .expect("distribution")
603 .insert("personal_data".into(), Probability(0.2));
604 }
605 }
606 let assessment = PrivacyPreflight::decision("private/classifier".into(), None)
607 .assess(&Request::default(), |call| async move {
608 let Call::Decision(call) = call else {
609 panic!("expected decision call");
610 };
611 call.respond(std::future::ready(Ok(response))).await
612 })
613 .await;
614 assert_eq!(assessment.reason_code(), "invalid_verdict");
615 assert_eq!(assessment.clear_score(), None);
616 assert!(!assessment.has_sensitive_content());
617 }
618 let preflight = PrivacyPreflight::llm(
619 "private/classifier".into(),
620 None,
621 ClassifierResponseFormat::JsonSchema,
622 )
623 .expect("judge");
624 let assessment = preflight
625 .assess(&Request::default(), |call| async move {
626 let Call::Model(call) = call else {
627 panic!("expected LLM call");
628 };
629 call.respond(std::future::ready(Ok(Response {
630 llm_response: LlmResponse::Stream(Box::pin(futures::stream::empty())),
631 metadata: None,
632 upstream_headers: Default::default(),
633 })))
634 .await
635 })
636 .await;
637 assert_eq!(assessment.reason_code(), "invalid_verdict");
638 }
639
640 #[tokio::test]
642 async fn bounds_full_context_and_preserves_judge_contracts() {
643 let mut request = Request {
644 llm_request: LlmRequest {
645 model: Some("public/answer".into()),
646 instructions: vec![InstructionBlock {
647 role: Role::System,
648 content: Message::text(Role::System, "system content").content,
649 }],
650 messages: vec![Message::text(Role::User, "user content")],
651 tools: vec![ToolDefinition {
652 name: "private_tool".into(),
653 description: Some("confidential tool content".into()),
654 parameters: json!({"type": "object", "properties": {"mcp_servers": {"type": "array"}}}),
655 strict: Some(true),
656 }
657 .into()],
658 ..Default::default()
659 },
660 raw_request: Some(json!({"host_only": "original body"})),
661 metadata: Some(Metadata {
662 task_id: Some("host-only-task".into()),
663 ..Default::default()
664 }),
665 };
666 request
667 .llm_request
668 .extensions
669 .fields
670 .insert("provider_content".into(), json!("private"));
671 request
672 .llm_request
673 .preservation
674 .requests
675 .insert("custom".into(), json!({"private": "preserved", "tools": [{"name": "lookup", "parameters": {"properties": {"mcp_servers": {"type": "mcp"}}}}]}));
676 for preflight in preflights() {
677 let assessment = preflight
678 .assess(&request, |call| async move {
679 let context = match &call {
680 Call::Decision(call) => {
681 assert_eq!(call.model.as_str(), "private/classifier");
682 assert_eq!(call.request.questions.len(), 1);
683 call.request.context.clone()
684 }
685 Call::Model(call) => {
686 assert!(call.request.raw_request.is_none());
687 assert!(call.request.metadata.is_none());
688 assert_eq!(call.request.llm_request.output.max_output_tokens, Some(512));
689 assert_eq!(call.request.llm_request.extensions.fields["store"], false);
690 assert_eq!(call.request.model_id(), Some("private/classifier".into()));
691 let prompt = Message {
692 role: Role::System,
693 content: call.request.llm_request.instructions[0].content.clone(),
694 }.text_content("").expect("prompt");
695 let expected = format!(
696 "{}\n\n{}\n\nReturn exactly one JSON object matching this JSON Schema:\n{:#}",
697 DEFAULT_PROMPT.trim(),
698 include_str!("prompts/privacy-classifier/llm.md").trim(),
699 verdict_schema(),
700 );
701 assert_eq!(prompt, expected);
702 let format = call.request.llm_request.output.response_format.as_ref().expect("format");
703 if format["type"] == "json_schema" {
704 assert_eq!(format["json_schema"]["name"], "switchyard_privacy_verdict");
705 assert_eq!(format["json_schema"]["strict"], true);
706 } else {
707 assert_eq!(format, &json!({"type": "json_object"}));
708 }
709 serde_json::from_str(&call.request.llm_request.messages[0].text_content("").expect("context")).expect("JSON")
710 }
711 };
712 assert_eq!(context["tools"][0]["name"], "private_tool");
713 assert_eq!(context["extensions"]["provider_content"], "private");
714 assert_eq!(context["preserved_requests"]["custom"]["private"], "preserved");
715 let serialized = context.to_string();
716 assert!(serialized.contains("system content"));
717 assert!(serialized.contains("user content"));
718 assert!(!serialized.contains("host-only-task"));
719 assert!(!serialized.contains("host_only"));
720 reply(call, "no_sensitive_content", 0.95).await
721 })
722 .await;
723 assert_eq!(assessment.reason_code(), "no_sensitive_content");
724 }
725 request.llm_request.messages =
726 vec![Message::text(Role::User, "x".repeat(MAX_CONTEXT_BYTES))];
727 let assessment = PrivacyPreflight::decision("private/classifier".into(), None)
728 .assess(&request, |_| async {
729 panic!("oversized input must not leave the host")
730 })
731 .await;
732 assert_eq!(assessment.reason_code(), "input_unavailable");
733 assert!(assessment.clear_score().is_none());
734 }
735
736 #[tokio::test]
738 async fn remote_tool_credentials_never_reach_assessment()
739 -> std::result::Result<(), serde_json::Error> {
740 let remote = json!({"type": "mcp", "name": "inventory", "url": "https://example.invalid/mcp", "authorization_token": "synthetic-secret"});
741 for input in [
742 json!({"tools": [remote.clone()]}),
743 json!({"tools": [{"type": "provider", "provider": "openai_responses", "raw": remote.clone()}]}),
744 json!({"extensions": {"fields": {"mcp_servers": [{"url": "https://example.invalid/mcp", "authorization_token": "synthetic-secret"}]}}}),
745 json!({"extensions": {"fields": {"switchyard_codex_additional_tools": [remote.clone()]}}}),
746 json!({"preservation": {"requests": {"openai_responses": {"tools": [remote.clone()]}}}}),
747 json!({"preservation": {"requests": {"openai_responses": {"input": [{"type": "additional_tools", "tools": [{"type": "namespace", "tools": [remote.clone()]}]}]}}}}),
748 ] {
749 let request = Request {
750 llm_request: serde_json::from_value(input)?,
751 ..Default::default()
752 };
753 for preflight in preflights() {
754 let assessment = preflight
755 .assess(&request, |_| async {
756 panic!("remote tool credentials must not reach the classifier")
757 })
758 .await;
759 assert_eq!(assessment.reason_code(), "input_unavailable");
760 assert_eq!(assessment.clear_score(), None);
761 }
762 }
763 Ok(())
764 }
765
766 #[tokio::test]
768 async fn cancellation_drops_pending_host_work() {
769 struct OnDrop(Arc<AtomicBool>);
770 impl Drop for OnDrop {
771 fn drop(&mut self) {
772 self.0.store(true, Ordering::SeqCst);
773 }
774 }
775 let dropped = Arc::new(AtomicBool::new(false));
776 let started = Arc::new(Notify::new());
777 let host_started = Arc::clone(&started);
778 let host_dropped = Arc::clone(&dropped);
779 let task = tokio::spawn(async move {
780 PrivacyPreflight::decision("private/classifier".into(), None)
781 .assess(&Request::default(), |call| async move {
782 let Call::Decision(call) = call else {
783 panic!("expected decision call");
784 };
785 call.respond(async move {
786 let _guard = OnDrop(host_dropped);
787 host_started.notify_one();
788 futures::future::pending().await
789 })
790 .await
791 })
792 .await
793 });
794 started.notified().await;
795 task.abort();
796 let Err(error) = task.await else {
797 panic!("assessment was not cancelled");
798 };
799 assert!(error.is_cancelled());
800 assert!(dropped.load(Ordering::SeqCst));
801 }
802}