Skip to main content

switchyard_libsy/
privacy_preflight.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Optional semantic privacy assessment through host-served judge calls.
5
6use 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
29/// A semantic assessment independent of lane selection and task retention.
30pub struct PrivacyAssessment {
31    reason_code: &'static str,
32    clear_score: Option<f64>,
33    has_sensitive_content: bool,
34}
35
36impl PrivacyAssessment {
37    /// Returns a bounded category or failure reason, without request or provider text.
38    pub const fn reason_code(&self) -> &'static str {
39        self.reason_code
40    }
41
42    /// Returns the model's clear-content score when the verdict is valid.
43    pub const fn clear_score(&self) -> Option<f64> {
44        self.clear_score
45    }
46
47    /// Reports an affirmative sensitive category; false also includes failures and is not clearance.
48    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
61/// Runs one optional privacy judge call through the host's existing [`Call`] handler.
62/// The caller applies thresholds, chooses permitted targets, and retains task restrictions.
63/// Routing and externally supplied restrictions do not require this detector.
64pub 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    /// Builds a typed decision question, optionally replacing its standing instructions.
77    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    /// Builds a JSON judge using the shared classifier contract and typed decoder.
96    /// Returns an error if the prompt or response contract is invalid.
97    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        // Both modes describe the schema, including endpoints that do not enforce it.
116        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    /// Assesses complete normalized content with one host-served call.
157    /// Failures return a bounded reason with no clear score. Dropping this future
158    /// cancels assessment and host work; no background task is spawned.
159    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        // Each assessment publishes one call; poll it with the verdict to preserve cancellation.
170        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// Borrow content-bearing fields; leave transport identity and cached responses out of assessment.
328#[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    // Exhaustive destructuring makes new protocol fields require a coverage decision.
343    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
392// Remote tool definitions contain transport credentials, including in preserved wire bodies.
393fn 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    // Invalid verdicts and recovered client errors never manufacture clearance or task evidence.
550    #[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    // Scan content-bearing fields without forwarding the original transport identity or auth.
641    #[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    // Normalized and preserved remote-tool definitions must not leave the host for assessment.
737    #[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    // Dropping assessment must cancel the host future, not leave a classifier task running.
767    #[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}