Skip to main content

agent_engine/runtime/
attachments.rs

1//! Offline preflight for canonical user attachments and rich tool results.
2//!
3//! This is a model **and** transport gate, not a model-name heuristic. Nothing
4//! here fetches a catalog, reads an attachment path, or contacts a provider.
5//! Call again on every outgoing history (including resume/model switches).
6//! Errors are static: neither payloads, titles, MIME strings nor model IDs from
7//! untrusted messages are interpolated into diagnostics.
8
9use base64::{engine::general_purpose::STANDARD, Engine as _};
10use serde::Serialize;
11use serde_json::Value;
12use std::io::{self, Write};
13
14use super::openai::{self, catalog, AuthPolicy, WireProtocol};
15use catalog::{capability_cache, CatalogModel, CatalogSource, Modality};
16
17/// Shared with attachment loaders; nested tool media counts toward this limit.
18pub const MAX_ATTACHMENTS_PER_MESSAGE: usize = 8;
19/// 3.5 MiB, matching the read tool's raw image bound.
20pub const MAX_IMAGE_BYTES: usize = 3_670_016;
21pub const MAX_PDF_BYTES: usize = 10 * 1024 * 1024;
22/// UTF-8 bytes, not Unicode scalar values.
23pub const MAX_TEXT_DOCUMENT_BYTES: usize = 256 * 1024;
24/// Encoded attachment blocks across the complete outgoing history.
25pub const MAX_HISTORY_ENCODED_BYTES: usize = 20 * 1024 * 1024;
26/// Keep the broker's existing bound authoritative; never raise it for media.
27pub const MAX_BROKER_PROXY_BYTES: usize = crate::auth::broker::MAX_PROXY_REQUEST_BYTES;
28
29// Preflight does not receive the system prompt/tools/options. Leave headroom
30// for that envelope and for wire lowering; the broker still checks the actual
31// complete serialized request. This is deliberately stricter than its cap.
32const BROKER_ENVELOPE_RESERVE_BYTES: usize = 64 * 1024;
33const WIRE_OVERHEAD_PER_ATTACHMENT: usize = 1024;
34const MAX_TITLE_BYTES: usize = 255;
35
36const BAD_ATTACHMENT: &str = "Attachment has an invalid canonical source or media type";
37const BAD_BASE64: &str = "Attachment has invalid base64 data";
38const TOO_LARGE: &str = "Attachment exceeds its byte limit";
39const HISTORY_LIMIT: &str = "Attachments exceed the encoded history byte limit";
40const BROKER_LIMIT: &str = "Attachment history exceeds the conservative 2 MiB broker proxy budget";
41const UNSUPPORTED_TRANSPORT: &str =
42    "Selected transport does not support image or document attachments";
43
44/// Validate canonical top-level user image/document blocks and media inside
45/// user `tool_result.content` arrays. Non-media tool inputs are not traversed:
46/// a JSON object supplied as tool arguments is data, not a content block.
47///
48/// Text-only histories remain unaffected, even on unsupported transports.
49pub fn validate_messages(model: &str, messages: &[crate::SharedMessage]) -> Result<(), String> {
50    let mut validator = Validator::new(model);
51    for message in messages {
52        let mut count = 0;
53        if let Some(blocks) = message.get("content").and_then(Value::as_array) {
54            validator.blocks(
55                blocks,
56                message.get("role").and_then(Value::as_str) == Some("user"),
57                false,
58                &mut count,
59            )?;
60        } else if message.get("content").is_some_and(contains_media) {
61            // A single media object is not canonical message content. Ordinary
62            // opaque/provider content is outside this attachment validator.
63            return Err("Attachments require an array of canonical content blocks".into());
64        }
65    }
66    validator.broker_budget(messages)
67}
68
69/// Validate one tool's rich output before committing it to a tool_result.
70/// Callers can replace Err with a plain-text tool error; no offending bytes
71/// need enter history. The outgoing full-history check remains mandatory,
72/// since sibling tool results share one message count/broker/history budget.
73pub fn validate_tool_blocks(model: &str, blocks: &[Value]) -> Result<(), String> {
74    let mut validator = Validator::new(model);
75    validator.blocks(blocks, true, true, &mut 0)?;
76    validator.broker_budget(blocks)
77}
78
79/// Assemble a sibling tool batch without admitting newly over-budget media.
80/// Strip only rich results introduced by this batch, newest first, preserving
81/// every correlation ID/order and returning explicit tool errors. Previously
82/// committed history is never rewritten. Its validity is checked before tools.
83pub(crate) fn bounded_tool_results(
84    model: &str,
85    history: &[crate::SharedMessage],
86    mut results: Vec<Value>,
87) -> Value {
88    let mut candidate = history.to_vec();
89    candidate.push(std::sync::Arc::new(
90        serde_json::json!({"role":"user","content":results}),
91    ));
92    for index in (0..results.len()).rev() {
93        let Err(error) = validate_messages(model, &candidate) else {
94            break;
95        };
96        if results[index].get("content").is_some_and(contains_media) {
97            results[index]["content"] = Value::String(format!("Attachment not sent: {error}"));
98            results[index]["is_error"] = Value::Bool(true);
99            *candidate.last_mut().expect("candidate tail") =
100                std::sync::Arc::new(serde_json::json!({"role":"user","content":results}));
101        }
102    }
103    serde_json::json!({"role":"user","content":results})
104}
105
106struct Transport {
107    wire: WireProtocol,
108    broker_proxy: bool,
109    image: bool,
110    pdf: bool,
111}
112
113impl Transport {
114    fn for_model(model: &str) -> Result<Self, String> {
115        // Explicit cloud invokes and extension providers precede ordinary
116        // runtime routing. Never borrow a same-named native model's evidence.
117        let (cloud_model, context) = crate::auth::cloud::split_model_route(model);
118        if context.is_some()
119            || cloud_model.split_once('/').is_some_and(|(provider, _)| {
120                provider.parse::<crate::auth::CloudProviderId>().is_ok()
121            })
122            || crate::extensions::providers::ProviderRegistry::parse_model_id(model).is_some()
123        {
124            return Err(UNSUPPORTED_TRANSPORT.into());
125        }
126        let (provider, id) = model.split_once('/').unwrap_or(("anthropic", model));
127        if provider.is_empty()
128            || id.is_empty()
129            || model.trim() != model
130            || model.chars().any(char::is_control)
131            || provider == "google-gemini"
132        {
133            return Err(UNSUPPORTED_TRANSPORT.into());
134        }
135
136        // `local` routing otherwise reads endpoint configuration. Its wire and
137        // proxy policy are fixed, and validation needs no endpoint/credentials.
138        let (wire, broker_proxy) = if provider == "local" {
139            (WireProtocol::OpenAiChatCompletions, true)
140        } else {
141            let route = openai::resolve_route(model).ok_or(UNSUPPORTED_TRANSPORT)?;
142            if route.provider != provider || route.model != id {
143                return Err(UNSUPPORTED_TRANSPORT.into());
144            }
145            (route.wire, route.auth == AuthPolicy::BrokerProxy)
146        };
147        if wire == WireProtocol::GoogleGeminiCodeAssist {
148            return Err(UNSUPPORTED_TRANSPORT.into());
149        }
150
151        let qualified = format!("{provider}/{id}");
152        let known_native = provider == "anthropic"
153            && agent_core::models::KNOWN_MODELS
154                .iter()
155                .any(|(known, _)| *known == id);
156        // A cache hit is authoritative, including a text-only/empty/unknown
157        // list: NEVER fall through to static image evidence after a hit.
158        let cached = capability_cache::get(&qualified);
159        let fallback = if cached.is_none() && provider == "openai-codex" {
160            catalog::codex_static_catalog_models()
161                .into_iter()
162                .find(|row| row.runtime_id() == qualified)
163        } else {
164            None
165        };
166        Ok(Self::with_evidence(
167            wire,
168            broker_proxy,
169            provider,
170            id,
171            known_native,
172            cached.as_ref(),
173            fallback.as_ref(),
174        ))
175    }
176
177    fn with_evidence(
178        wire: WireProtocol,
179        broker_proxy: bool,
180        provider: &str,
181        id: &str,
182        known_native: bool,
183        cached: Option<&CatalogModel>,
184        fallback: Option<&CatalogModel>,
185    ) -> Self {
186        let advertised = |modality: Modality| {
187            let row = cached.or(fallback);
188            row.is_some_and(|row| {
189                // Generic compatible routes require exact LIVE advertising.
190                // Codex alone has an evidence-backed static modality table.
191                row.provider_key == provider
192                    && row.id == id
193                    && (row.source == CatalogSource::Live
194                        || (provider == "openai-codex"
195                            && row.source == CatalogSource::StaticFallback))
196                    && row.input_modalities.contains(&modality)
197            })
198        };
199        // The native catalog parser currently defaults every row to Text and
200        // does not parse modality metadata. Its cache cannot revoke known
201        // native vision/PDF evidence. Only the exact KNOWN_MODELS list grants
202        // native binary support; unknown native rows never gain it via cache.
203        let image = if wire == WireProtocol::AnthropicMessages {
204            known_native
205        } else {
206            advertised(Modality::Image)
207        };
208        let pdf = match wire {
209            WireProtocol::AnthropicMessages => known_native,
210            WireProtocol::OpenAiChatCompletions
211            | WireProtocol::OpenAiResponses
212            | WireProtocol::CodexResponses => advertised(Modality::File),
213            WireProtocol::GoogleGeminiCodeAssist => false,
214        };
215        Self {
216            wire,
217            broker_proxy,
218            image,
219            pdf,
220        }
221    }
222
223    fn permits(&self, kind: AttachmentKind) -> Result<(), String> {
224        if !matches!(
225            self.wire,
226            WireProtocol::AnthropicMessages
227                | WireProtocol::OpenAiChatCompletions
228                | WireProtocol::OpenAiResponses
229                | WireProtocol::CodexResponses
230        ) {
231            return Err(UNSUPPORTED_TRANSPORT.into());
232        }
233        match kind {
234            AttachmentKind::Image if !self.image => {
235                Err("Selected model lacks exact image input capability metadata".into())
236            }
237            AttachmentKind::Pdf if !self.pdf => {
238                Err("Selected model and transport lack supported PDF file input capability".into())
239            }
240            // Text documents are deliberately kept as documents until the
241            // supported wire adapter lowers them to lower-authority text.
242            _ => Ok(()),
243        }
244    }
245}
246
247#[derive(Clone, Copy)]
248enum AttachmentKind {
249    Image,
250    Pdf,
251    Text,
252}
253
254struct Validator<'a> {
255    model: &'a str,
256    transport: Option<Transport>,
257    encoded_bytes: usize,
258    attachments: usize,
259}
260
261impl<'a> Validator<'a> {
262    fn new(model: &'a str) -> Self {
263        Self {
264            model,
265            transport: None,
266            encoded_bytes: 0,
267            attachments: 0,
268        }
269    }
270
271    fn blocks(
272        &mut self,
273        blocks: &[Value],
274        user: bool,
275        in_tool_result: bool,
276        count: &mut usize,
277    ) -> Result<(), String> {
278        for block in blocks {
279            let kind = block.get("type").and_then(Value::as_str);
280            match kind {
281                Some("image" | "document") => {
282                    if !user {
283                        return Err(
284                            "Attachments are only supported in user content or tool results".into(),
285                        );
286                    }
287                    *count += 1;
288                    if *count > MAX_ATTACHMENTS_PER_MESSAGE {
289                        return Err(
290                            "A message may contain at most 8 attachments, including tool media"
291                                .into(),
292                        );
293                    }
294                    self.attachment(block)?;
295                }
296                Some("tool_result") => {
297                    if let Some(content) = block.get("content") {
298                        match content {
299                            Value::Array(nested) if user && !in_tool_result => {
300                                self.blocks(nested, true, true, count)?;
301                            }
302                            _ if contains_media(content) => {
303                                return Err(
304                                    "Tool attachments require a canonical user tool_result array"
305                                        .into(),
306                                );
307                            }
308                            _ => {}
309                        }
310                    }
311                    if block.get("source").is_some() {
312                        return Err(BAD_ATTACHMENT.into());
313                    }
314                }
315                // Preserve ordinary text/tool inputs/thinking/signatures and
316                // opaque provider blocks. Do not walk tool_use.input: objects
317                // inside arguments are data, not canonical media containers.
318                _ if is_media_block(block) || (block.is_array() && contains_media(block)) => {
319                    return Err("Unsupported or malformed canonical media block".into());
320                }
321                _ => {}
322            }
323        }
324        Ok(())
325    }
326
327    fn attachment(&mut self, block: &Value) -> Result<(), String> {
328        if self.transport.is_none() {
329            self.transport = Some(Transport::for_model(self.model)?);
330        }
331        let kind = validate_attachment(block)?;
332        self.transport
333            .as_ref()
334            .expect("transport resolved")
335            .permits(kind)?;
336        let remaining = MAX_HISTORY_ENCODED_BYTES.saturating_sub(self.encoded_bytes);
337        self.encoded_bytes += serialized_size(block, remaining).ok_or(HISTORY_LIMIT)?;
338        self.attachments += 1;
339        Ok(())
340    }
341
342    fn broker_budget<T: Serialize + ?Sized>(&self, content: &T) -> Result<(), String> {
343        if !self.transport.as_ref().is_some_and(|t| t.broker_proxy) {
344            return Ok(());
345        }
346        let overhead = self
347            .attachments
348            .saturating_mul(WIRE_OVERHEAD_PER_ATTACHMENT);
349        let remaining = MAX_BROKER_PROXY_BYTES
350            .saturating_sub(BROKER_ENVELOPE_RESERVE_BYTES)
351            .saturating_sub(overhead);
352        serialized_size(content, remaining).ok_or(BROKER_LIMIT)?;
353        Ok(())
354    }
355}
356
357fn validate_attachment(block: &Value) -> Result<AttachmentKind, String> {
358    let object = block.as_object().ok_or(BAD_ATTACHMENT)?;
359    if object
360        .keys()
361        .any(|key| !matches!(key.as_str(), "type" | "source" | "title" | "cache_control"))
362    {
363        return Err(BAD_ATTACHMENT.into());
364    }
365    let source = block
366        .get("source")
367        .and_then(Value::as_object)
368        .ok_or(BAD_ATTACHMENT)?;
369    // Exact canonical sources only. A base64 source with a second url/file_id
370    // route is not accepted, even if its inline data happens to be valid.
371    if source.len() != 3
372        || source
373            .keys()
374            .any(|key| !matches!(key.as_str(), "type" | "media_type" | "data"))
375    {
376        return Err(BAD_ATTACHMENT.into());
377    }
378    let source_type = source
379        .get("type")
380        .and_then(Value::as_str)
381        .ok_or(BAD_ATTACHMENT)?;
382    let mime = source
383        .get("media_type")
384        .and_then(Value::as_str)
385        .ok_or(BAD_ATTACHMENT)?;
386    let data = source
387        .get("data")
388        .and_then(Value::as_str)
389        .ok_or(BAD_ATTACHMENT)?;
390    if let Some(title) = block.get("title") {
391        validate_title(title)?;
392    }
393    match (block.get("type").and_then(Value::as_str), source_type, mime) {
394        (Some("image"), "base64", "image/png" | "image/jpeg" | "image/gif" | "image/webp") => {
395            if block.get("title").is_some() {
396                return Err(BAD_ATTACHMENT.into());
397            }
398            let bytes = decode_bounded(data, MAX_IMAGE_BYTES)?;
399            if crate::tools::read::sniff_image_mime(&bytes) != Some(mime) {
400                return Err("Image attachment data does not match its declared media type".into());
401            }
402            // Call integrity only after the sniff has established the minimum
403            // header length (notably WebP's RIFF size field).
404            if crate::tools::read::image_integrity_error(mime, &bytes).is_some()
405                || !crate::tools::read::image_dimensions(mime, &bytes)
406                    .is_some_and(|(w, h)| (1..=8000).contains(&w) && (1..=8000).contains(&h))
407            {
408                return Err("Image attachment is corrupt or has unsupported dimensions".into());
409            }
410            Ok(AttachmentKind::Image)
411        }
412        (Some("document"), "base64", "application/pdf") => {
413            let bytes = decode_bounded(data, MAX_PDF_BYTES)?;
414            if !bytes.starts_with(b"%PDF-")
415                || !bytes
416                    .windows(5)
417                    .rev()
418                    .take(1024)
419                    .any(|window| window == b"%%EOF")
420            {
421                return Err(
422                    "PDF attachment is invalid or does not match its declared media type".into(),
423                );
424            }
425            Ok(AttachmentKind::Pdf)
426        }
427        (Some("document"), "text", "text/plain") => {
428            // A text document's title is the filename, not a filesystem path.
429            validate_title(block.get("title").ok_or(BAD_ATTACHMENT)?)?;
430            if data.len() > MAX_TEXT_DOCUMENT_BYTES {
431                return Err(TOO_LARGE.into());
432            }
433            Ok(AttachmentKind::Text)
434        }
435        _ => Err(BAD_ATTACHMENT.into()),
436    }
437}
438
439fn validate_title(title: &Value) -> Result<(), String> {
440    let title = title.as_str().ok_or(BAD_ATTACHMENT)?;
441    if title.is_empty()
442        || title.len() > MAX_TITLE_BYTES
443        || matches!(title, "." | "..")
444        || title.contains(['/', '\\'])
445        || title.chars().any(char::is_control)
446    {
447        return Err("Document attachment title must be a bounded filename".into());
448    }
449    Ok(())
450}
451
452fn decode_bounded(data: &str, max_bytes: usize) -> Result<Vec<u8>, String> {
453    // Check before decoding/allocation. STANDARD also enforces canonical
454    // padding/trailing bits and rejects whitespace, URL-safe data, and URIs.
455    let max_encoded = max_bytes.div_ceil(3) * 4;
456    if data.len() > max_encoded {
457        return Err(TOO_LARGE.into());
458    }
459    let decoded = STANDARD.decode(data).map_err(|_| BAD_BASE64)?;
460    if decoded.is_empty() {
461        return Err(BAD_BASE64.into());
462    }
463    if decoded.len() > max_bytes {
464        return Err(TOO_LARGE.into());
465    }
466    Ok(decoded)
467}
468
469// Media-shaped canonical/wire blocks must never bypass the source gate.
470// Do not inspect arbitrary nested values (e.g. a tool's JSON arguments).
471fn is_media_block(block: &Value) -> bool {
472    block.get("source").is_some()
473        || [
474            "image_url",
475            "input_image",
476            "input_file",
477            "file_id",
478            "file_data",
479            "mimeType",
480        ]
481        .iter()
482        .any(|key| block.get(key).is_some())
483        || matches!(
484            block.get("type").and_then(Value::as_str),
485            Some(
486                "image"
487                    | "document"
488                    | "image_url"
489                    | "input_image"
490                    | "input_file"
491                    | "file"
492                    | "audio"
493                    | "input_audio"
494                    | "video"
495                    | "resource"
496                    | "resource_link"
497            )
498        )
499}
500
501// Iterative traversal of canonical containers only, so malformed nested tool
502// results cannot hide media or trigger unbounded Rust recursion.
503fn contains_media(content: &Value) -> bool {
504    let mut pending = vec![content];
505    while let Some(value) = pending.pop() {
506        if is_media_block(value) {
507            return true;
508        }
509        if let Some(blocks) = value.as_array() {
510            pending.extend(blocks);
511        } else if value.get("type").and_then(Value::as_str) == Some("tool_result") {
512            if let Some(nested) = value.get("content") {
513                pending.push(nested);
514            }
515        }
516    }
517    false
518}
519
520/// Count JSON bytes, including escaping, without allocating a payload copy.
521fn serialized_size<T: Serialize + ?Sized>(value: &T, limit: usize) -> Option<usize> {
522    struct Counter {
523        bytes: usize,
524        limit: usize,
525    }
526    impl Write for Counter {
527        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
528            if bytes.len() > self.limit.saturating_sub(self.bytes) {
529                return Err(io::Error::other("attachment serialization budget exceeded"));
530            }
531            self.bytes += bytes.len();
532            Ok(bytes.len())
533        }
534        fn flush(&mut self) -> io::Result<()> {
535            Ok(())
536        }
537    }
538    let mut counter = Counter { bytes: 0, limit };
539    serde_json::to_writer(&mut counter, value).ok()?;
540    Some(counter.bytes)
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use serde_json::json;
547    use std::sync::Arc;
548
549    const NATIVE: &str = "anthropic/claude-sonnet-4-6";
550    const ASTRA: &str = "openai-codex/gpt-6-astra";
551    const SPARK: &str = "openai-codex/gpt-5.3-codex-spark";
552    const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=";
553
554    fn image() -> Value {
555        json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG}})
556    }
557
558    fn pdf() -> Value {
559        // Exercises the validator's bounded header/trailer check, not a PDF renderer.
560        json!({"type":"document","title":"sample.pdf","source":{
561            "type":"base64","media_type":"application/pdf",
562            "data":STANDARD.encode(b"%PDF-1.7\n%%EOF\n")
563        }})
564    }
565
566    fn text(data: &str) -> Value {
567        json!({"type":"document","title":"notes.txt","source":{
568            "type":"text","media_type":"text/plain","data":data
569        }})
570    }
571
572    fn message(blocks: Vec<Value>) -> crate::SharedMessage {
573        Arc::new(json!({"role":"user","content":blocks}))
574    }
575
576    fn check(model: &str, block: Value) -> Result<(), String> {
577        validate_messages(model, &[message(vec![block])])
578    }
579
580    fn row(
581        provider: &str,
582        id: &str,
583        modalities: Vec<Modality>,
584        source: CatalogSource,
585    ) -> CatalogModel {
586        let mut row = CatalogModel::new(provider, provider, id).unwrap();
587        row.input_modalities = modalities;
588        row.source = source;
589        row
590    }
591
592    #[test]
593    fn combined_tool_batch_rejects_only_overflowing_media_and_preserves_ids() {
594        let results: Vec<_> = (0..9).map(|i| json!({"type":"tool_result", "tool_use_id":format!("tool_{i}"), "content":[image()]})).collect();
595        let bounded = bounded_tool_results(NATIVE, &[], results);
596        assert!(validate_messages(NATIVE, &[Arc::new(bounded.clone())]).is_ok());
597        for i in 0..9 {
598            assert_eq!(bounded["content"][i]["tool_use_id"], format!("tool_{i}"));
599        }
600        assert!(bounded["content"][7]["content"].is_array());
601        assert_eq!(bounded["content"][8]["is_error"], true);
602        assert!(bounded["content"][8]["content"]
603            .as_str()
604            .unwrap()
605            .starts_with("Attachment not sent:"));
606    }
607
608    #[test]
609    fn astra_image_and_text_work_but_static_pdf_and_spark_image_do_not() {
610        assert!(check(ASTRA, image()).is_ok());
611        assert!(validate_tool_blocks(ASTRA, &[image()]).is_ok());
612        assert!(check(ASTRA, text("attached text")).is_ok());
613        assert!(check(ASTRA, pdf()).is_err());
614        assert!(check(SPARK, image()).is_err());
615        assert!(check(SPARK, text("still a text model")).is_ok());
616        assert!(check("openai-codex/gpt-5.4", image()).is_err());
617        assert!(check("openai-codex/gpt-6-astra-unobserved", image()).is_err());
618    }
619
620    #[test]
621    fn exact_native_anthropic_accepts_image_pdf_and_text() {
622        for model in [NATIVE, "claude-sonnet-4-6"] {
623            assert!(
624                validate_messages(model, &[message(vec![image(), pdf(), text("hello")])]).is_ok()
625            );
626            assert!(validate_tool_blocks(model, &[image(), pdf(), text("hello")]).is_ok());
627        }
628        for model in [
629            "anthropic/claude-unobserved",
630            "claude-sonnet-4-6-unobserved",
631        ] {
632            assert!(check(model, image()).is_err());
633            assert!(check(model, pdf()).is_err());
634        }
635        // Native catalog rows default to Text; they cannot accidentally revoke
636        // the independently maintained exact native vision/PDF evidence.
637        let cached = row(
638            "anthropic",
639            "claude-sonnet-4-6",
640            vec![Modality::Text],
641            CatalogSource::Live,
642        );
643        let transport = Transport::with_evidence(
644            WireProtocol::AnthropicMessages,
645            false,
646            "anthropic",
647            "claude-sonnet-4-6",
648            true,
649            Some(&cached),
650            None,
651        );
652        assert!(transport.permits(AttachmentKind::Image).is_ok());
653        assert!(transport.permits(AttachmentKind::Pdf).is_ok());
654    }
655
656    #[test]
657    fn exact_live_cache_support_and_revocation_are_rechecked_for_generic_and_codex() {
658        // Unique IDs avoid modifying real catalog rows or clearing shared cache
659        // state while the suite runs in parallel.
660        for provider in ["local", "openai-codex"] {
661            let id = "attachment-validator-live-revocation-sa26";
662            let model = format!("{provider}/{id}");
663            let history = [message(vec![image(), pdf()])];
664            assert!(validate_messages(&model, &history).is_err());
665            capability_cache::insert(row(
666                provider,
667                id,
668                vec![Modality::Image, Modality::File],
669                CatalogSource::Live,
670            ));
671            assert!(validate_messages(&model, &history).is_ok());
672            assert!(validate_tool_blocks(&model, &[image(), pdf()]).is_ok());
673            for modalities in [vec![Modality::Text], vec![]] {
674                capability_cache::insert(row(provider, id, modalities, CatalogSource::Live));
675                assert!(validate_messages(&model, &history).is_err());
676                assert!(validate_tool_blocks(&model, &[image()]).is_err());
677                assert!(check(&model, pdf()).is_err());
678                assert!(check(&model, text("text needs no binary capability")).is_ok());
679            }
680        }
681    }
682
683    #[test]
684    fn generic_cache_requires_exact_provider_id_and_live_source() {
685        let id = "attachment-validator-provider-scope-sa26";
686        capability_cache::insert(row(
687            "groq",
688            id,
689            vec![Modality::Image, Modality::File],
690            CatalogSource::Live,
691        ));
692        assert!(check(&format!("local/{id}"), image()).is_err());
693        assert!(check(&format!("local/{id}"), pdf()).is_err());
694        for source in [
695            CatalogSource::StaticFallback,
696            CatalogSource::StaticWithLive,
697            CatalogSource::Inferred,
698        ] {
699            capability_cache::insert(row(
700                "local",
701                id,
702                vec![Modality::Image, Modality::File],
703                source,
704            ));
705            assert!(check(&format!("local/{id}"), image()).is_err());
706            assert!(check(&format!("local/{id}"), pdf()).is_err());
707        }
708        let wrong = row(
709            "local",
710            "different-model",
711            vec![Modality::Image],
712            CatalogSource::Live,
713        );
714        let transport = Transport::with_evidence(
715            WireProtocol::OpenAiChatCompletions,
716            true,
717            "local",
718            id,
719            false,
720            Some(&wrong),
721            None,
722        );
723        assert!(transport.permits(AttachmentKind::Image).is_err());
724    }
725
726    #[test]
727    fn cached_codex_revocation_overrides_astra_static_evidence_without_global_mutation() {
728        let fallback = catalog::codex_static_catalog_models()
729            .into_iter()
730            .find(|row| row.runtime_id() == ASTRA)
731            .unwrap();
732        for modalities in [vec![Modality::Text], vec![]] {
733            let cached = row(
734                "openai-codex",
735                "gpt-6-astra",
736                modalities,
737                CatalogSource::Live,
738            );
739            let transport = Transport::with_evidence(
740                WireProtocol::CodexResponses,
741                false,
742                "openai-codex",
743                "gpt-6-astra",
744                false,
745                Some(&cached),
746                Some(&fallback),
747            );
748            assert!(transport.permits(AttachmentKind::Image).is_err());
749            assert!(transport.permits(AttachmentKind::Pdf).is_err());
750        }
751    }
752
753    #[test]
754    fn supported_wire_protocols_require_separate_image_and_file_evidence() {
755        for wire in [
756            WireProtocol::OpenAiChatCompletions,
757            WireProtocol::OpenAiResponses,
758            WireProtocol::CodexResponses,
759        ] {
760            let cached = row(
761                "local",
762                "wire-evidence",
763                vec![Modality::Image],
764                CatalogSource::Live,
765            );
766            let transport = Transport::with_evidence(
767                wire,
768                false,
769                "local",
770                "wire-evidence",
771                false,
772                Some(&cached),
773                None,
774            );
775            assert!(transport.permits(AttachmentKind::Image).is_ok());
776            assert!(transport.permits(AttachmentKind::Pdf).is_err());
777            assert!(transport.permits(AttachmentKind::Text).is_ok());
778        }
779    }
780
781    #[test]
782    fn unsupported_cloud_gemini_extensions_reject_all_attachment_kinds() {
783        for model in [
784            "aws-bedrock/anthropic.claude-3-haiku",
785            "aws-bedrock/anthropic.claude-3-haiku#synaps-context=ctx-test",
786            "google-vertex/claude-sonnet-4-6",
787            "azure-openai/gpt-6-astra",
788            "google-gemini/gemini-2.5-pro",
789            "plugin:provider:model",
790        ] {
791            for block in [image(), pdf(), text("not even text documents")] {
792                assert_eq!(
793                    check(model, block.clone()).unwrap_err(),
794                    UNSUPPORTED_TRANSPORT,
795                    "{model}"
796                );
797                assert_eq!(
798                    validate_tool_blocks(model, &[block]).unwrap_err(),
799                    UNSUPPORTED_TRANSPORT,
800                    "{model}"
801                );
802            }
803            assert!(validate_messages(
804                model,
805                &[message(vec![json!({"type":"text","text":"ordinary text"})])]
806            )
807            .is_ok());
808        }
809    }
810
811    #[test]
812    fn invalid_base64_is_rejected_without_echoing_payload() {
813        for data in [
814            "",
815            "private-payload%%%",
816            "Zg",
817            "Zh==",
818            "Zg==\n",
819            "____",
820            "data:image/png;base64,Zg==",
821        ] {
822            let mut block = image();
823            block["source"]["data"] = json!(data);
824            assert_eq!(check(NATIVE, block).unwrap_err(), BAD_BASE64);
825        }
826    }
827
828    #[test]
829    fn corrupt_mismatched_and_out_of_bounds_images_are_rejected() {
830        let bytes = STANDARD.decode(PNG).unwrap();
831        let mut truncated = image();
832        truncated["source"]["data"] = json!(STANDARD.encode(&bytes[..bytes.len() - 1]));
833        assert!(check(NATIVE, truncated).unwrap_err().contains("corrupt"));
834        let mut mismatch = image();
835        mismatch["source"]["media_type"] = json!("image/jpeg");
836        assert!(check(NATIVE, mismatch)
837            .unwrap_err()
838            .contains("declared media type"));
839        for width in [0_u32, 8001] {
840            let mut invalid = bytes.clone();
841            invalid[16..20].copy_from_slice(&width.to_be_bytes());
842            let mut block = image();
843            block["source"]["data"] = json!(STANDARD.encode(invalid));
844            assert!(check(NATIVE, block).unwrap_err().contains("dimensions"));
845        }
846        for bytes in [
847            b"\x89PNG\r\n\x1a\n".as_slice(),
848            b"RIFF\0\0\0\0WEBP".as_slice(),
849        ] {
850            let mut block = image();
851            block["source"]["media_type"] =
852                json!(crate::tools::read::sniff_image_mime(bytes).unwrap());
853            block["source"]["data"] = json!(STANDARD.encode(bytes));
854            assert!(check(NATIVE, block).is_err());
855        }
856    }
857
858    #[test]
859    fn pdf_requires_matching_header_and_nearby_eof() {
860        for bytes in [
861            b"not a PDF %%EOF".to_vec(),
862            b"%PDF-1.7\ntruncated".to_vec(),
863            [b"%PDF-1.7\n%%EOF".as_slice(), &vec![b'x'; 1030]].concat(),
864        ] {
865            let mut block = pdf();
866            block["source"]["data"] = json!(STANDARD.encode(bytes));
867            assert!(check(NATIVE, block)
868                .unwrap_err()
869                .starts_with("PDF attachment is invalid"));
870        }
871    }
872
873    #[test]
874    fn canonical_sources_mime_and_titles_are_exact() {
875        for (key, value) in [
876            ("type", json!("url")),
877            ("media_type", json!("IMAGE/PNG")),
878            ("data", json!(null)),
879            ("url", json!("https://example.invalid/private")),
880            ("file_id", json!("secret-id")),
881        ] {
882            let mut block = image();
883            block["source"][key] = value;
884            assert_eq!(check(NATIVE, block).unwrap_err(), BAD_ATTACHMENT);
885        }
886        let mut titled_image = image();
887        titled_image["title"] = json!("image.png");
888        assert_eq!(check(NATIVE, titled_image).unwrap_err(), BAD_ATTACHMENT);
889        for title in [
890            "",
891            ".",
892            "..",
893            "../private.txt",
894            "a/b",
895            "a\\b",
896            "private\nname",
897        ] {
898            let mut block = text("private-payload");
899            block["title"] = json!(title);
900            assert_eq!(
901                check(NATIVE, block).unwrap_err(),
902                "Document attachment title must be a bounded filename"
903            );
904        }
905        let mut missing_title = text("private-payload");
906        missing_title.as_object_mut().unwrap().remove("title");
907        assert_eq!(check(NATIVE, missing_title).unwrap_err(), BAD_ATTACHMENT);
908        let mut long_title = text("x");
909        long_title["title"] = json!("x".repeat(MAX_TITLE_BYTES + 1));
910        assert!(check(NATIVE, long_title).is_err());
911        for mime in [
912            "text/html",
913            "application/octet-stream",
914            "text/plain; charset=utf-8",
915        ] {
916            let mut block = text("x");
917            block["source"]["media_type"] = json!(mime);
918            assert_eq!(check(NATIVE, block).unwrap_err(), BAD_ATTACHMENT);
919        }
920    }
921
922    #[test]
923    fn byte_bounds_cover_encoded_and_decoded_lengths_and_utf8() {
924        assert_eq!(MAX_IMAGE_BYTES, 3_670_016);
925        assert_eq!(MAX_PDF_BYTES, 10 * 1024 * 1024);
926        assert_eq!(MAX_TEXT_DOCUMENT_BYTES, 256 * 1024);
927        assert_eq!(MAX_HISTORY_ENCODED_BYTES, 20 * 1024 * 1024);
928        assert_eq!(MAX_BROKER_PROXY_BYTES, 2 * 1024 * 1024);
929        assert_eq!(
930            decode_bounded(&STANDARD.encode([0_u8; 2]), 2).unwrap(),
931            vec![0, 0]
932        );
933        // Three bytes fit the same encoded-length bound as two; the decoded
934        // bound must still reject them after decoding.
935        assert_eq!(
936            decode_bounded(&STANDARD.encode([0_u8; 3]), 2).unwrap_err(),
937            TOO_LARGE
938        );
939        for (mut block, limit) in [(image(), MAX_IMAGE_BYTES), (pdf(), MAX_PDF_BYTES)] {
940            block["source"]["data"] = json!("A".repeat(limit.div_ceil(3) * 4 + 4));
941            assert_eq!(check(NATIVE, block).unwrap_err(), TOO_LARGE);
942        }
943        let at_limit = "é".repeat(MAX_TEXT_DOCUMENT_BYTES / 2);
944        assert!(check(NATIVE, text(&at_limit)).is_ok());
945        assert_eq!(
946            check(NATIVE, text(&(at_limit + "é"))).unwrap_err(),
947            TOO_LARGE
948        );
949    }
950
951    #[test]
952    fn nested_tool_results_share_message_count_but_history_messages_do_not() {
953        let seven = vec![image(); MAX_ATTACHMENTS_PER_MESSAGE - 1];
954        let nested = json!({"type":"tool_result","tool_use_id":"read-1","content":seven});
955        assert!(validate_messages(NATIVE, &[message(vec![image(), nested.clone()])]).is_ok());
956        let error =
957            validate_messages(NATIVE, &[message(vec![image(), image(), nested])]).unwrap_err();
958        assert_eq!(
959            error,
960            "A message may contain at most 8 attachments, including tool media"
961        );
962        assert!(validate_tool_blocks(NATIVE, &vec![image(); MAX_ATTACHMENTS_PER_MESSAGE]).is_ok());
963        assert!(
964            validate_tool_blocks(NATIVE, &vec![image(); MAX_ATTACHMENTS_PER_MESSAGE + 1]).is_err()
965        );
966        let full = message(vec![image(); MAX_ATTACHMENTS_PER_MESSAGE]);
967        assert!(validate_messages(NATIVE, &[full.clone(), full]).is_ok());
968    }
969
970    #[test]
971    fn encoded_history_budget_covers_all_messages_and_json_escaping() {
972        let full = message(vec![
973            text(&"x".repeat(MAX_TEXT_DOCUMENT_BYTES));
974            MAX_ATTACHMENTS_PER_MESSAGE
975        ]);
976        assert!(validate_messages(NATIVE, &vec![full.clone(); 9]).is_ok());
977        assert_eq!(
978            validate_messages(NATIVE, &vec![full; 10]).unwrap_err(),
979            HISTORY_LIMIT
980        );
981        let escaped = text(&"\n".repeat(MAX_TEXT_DOCUMENT_BYTES));
982        assert!(serialized_size(&escaped, MAX_TEXT_DOCUMENT_BYTES).is_none());
983        let size = serialized_size(&escaped, usize::MAX).unwrap();
984        assert_eq!(serialized_size(&escaped, size), Some(size));
985        assert_eq!(serialized_size(&escaped, size - 1), None);
986    }
987
988    #[test]
989    fn broker_budget_counts_ordinary_history_and_keeps_envelope_reserve() {
990        let full = text(&"x".repeat(MAX_TEXT_DOCUMENT_BYTES));
991        assert!(validate_messages(
992            "local/attachment-validator-budget-sa26",
993            &[message(vec![full.clone(); 7])]
994        )
995        .is_ok());
996        assert_eq!(
997            validate_messages(
998                "local/attachment-validator-budget-sa26",
999                &[message(vec![full; 8])]
1000            )
1001            .unwrap_err(),
1002            BROKER_LIMIT
1003        );
1004        let history = [message(vec![
1005            text("small"),
1006            json!({"type":"text","text":"x".repeat(MAX_BROKER_PROXY_BYTES)}),
1007        ])];
1008        assert_eq!(
1009            validate_messages("local/attachment-validator-budget-sa26", &history).unwrap_err(),
1010            BROKER_LIMIT
1011        );
1012        assert!(validate_messages(NATIVE, &history).is_ok());
1013    }
1014
1015    #[test]
1016    fn malformed_media_containers_fail_but_opaque_tool_inputs_are_not_walked() {
1017        let assistant = Arc::new(json!({"role":"assistant","content":[image()]}));
1018        assert!(validate_messages(NATIVE, &[assistant]).is_err());
1019        let single = Arc::new(json!({"role":"user","content":image()}));
1020        assert!(validate_messages(NATIVE, &[single]).is_err());
1021        for block in [
1022            json!({"type":"image_url","image_url":{"url":"https://example.invalid/image"}}),
1023            json!({"type":"input_file","file_id":"private-id"}),
1024            json!({"type":"tool_result","content":image()}),
1025            json!({"type":"tool_result","content":[{"type":"tool_result","content":[image()]}]}),
1026            json!([image()]),
1027        ] {
1028            assert!(check(NATIVE, block).is_err());
1029        }
1030        let opaque = Arc::new(json!({"role":"assistant","content":[
1031            {"type":"tool_use","id":"t","name":"example","input":{"type":"image","source":{"data":"not media"}}},
1032            {"type":"thinking","thinking":"ordinary reasoning","signature":"opaque"},
1033            {"type":"provider_extension","value":"opaque"}
1034        ]}));
1035        assert!(validate_messages("plugin:provider:model", &[opaque]).is_ok());
1036        assert!(
1037            validate_tool_blocks(NATIVE, &[json!({"type":"tool_result","content":[image()]})])
1038                .is_err()
1039        );
1040    }
1041}