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    /// Regression: before Opus 5.5 was an exact known native model, every
657    /// `read` of an image on it came back as
658    /// "Attachment not sent: Selected model lacks exact image input capability metadata".
659    #[test]
660    fn opus_5_5_native_accepts_image_tool_results_pdf_and_text() {
661        for model in ["anthropic/claude-opus-5-5", "claude-opus-5-5"] {
662            assert!(validate_tool_blocks(model, &[image()]).is_ok(), "{model}");
663            assert!(
664                validate_messages(model, &[message(vec![image(), pdf(), text("hello")])]).is_ok(),
665                "{model}"
666            );
667            let bounded = bounded_tool_results(
668                model,
669                &[],
670                vec![json!({"type":"tool_result","tool_use_id":"toolu_read","content":[image()]})],
671            );
672            assert!(bounded["content"][0]["content"].is_array(), "{model}");
673            assert!(bounded["content"][0].get("is_error").is_none(), "{model}");
674        }
675        // Exact ids only: dotted or suffixed spellings stay fail-closed.
676        for model in [
677            "anthropic/claude-opus-5.5",
678            "anthropic/claude-opus-5-5-preview",
679        ] {
680            assert!(check(model, image()).is_err(), "{model}");
681        }
682    }
683
684    #[test]
685    fn exact_live_cache_support_and_revocation_are_rechecked_for_generic_and_codex() {
686        // Unique IDs avoid modifying real catalog rows or clearing shared cache
687        // state while the suite runs in parallel.
688        for provider in ["local", "openai-codex"] {
689            let id = "attachment-validator-live-revocation-sa26";
690            let model = format!("{provider}/{id}");
691            let history = [message(vec![image(), pdf()])];
692            assert!(validate_messages(&model, &history).is_err());
693            capability_cache::insert(row(
694                provider,
695                id,
696                vec![Modality::Image, Modality::File],
697                CatalogSource::Live,
698            ));
699            assert!(validate_messages(&model, &history).is_ok());
700            assert!(validate_tool_blocks(&model, &[image(), pdf()]).is_ok());
701            for modalities in [vec![Modality::Text], vec![]] {
702                capability_cache::insert(row(provider, id, modalities, CatalogSource::Live));
703                assert!(validate_messages(&model, &history).is_err());
704                assert!(validate_tool_blocks(&model, &[image()]).is_err());
705                assert!(check(&model, pdf()).is_err());
706                assert!(check(&model, text("text needs no binary capability")).is_ok());
707            }
708        }
709    }
710
711    #[test]
712    fn generic_cache_requires_exact_provider_id_and_live_source() {
713        let id = "attachment-validator-provider-scope-sa26";
714        capability_cache::insert(row(
715            "groq",
716            id,
717            vec![Modality::Image, Modality::File],
718            CatalogSource::Live,
719        ));
720        assert!(check(&format!("local/{id}"), image()).is_err());
721        assert!(check(&format!("local/{id}"), pdf()).is_err());
722        for source in [
723            CatalogSource::StaticFallback,
724            CatalogSource::StaticWithLive,
725            CatalogSource::Inferred,
726        ] {
727            capability_cache::insert(row(
728                "local",
729                id,
730                vec![Modality::Image, Modality::File],
731                source,
732            ));
733            assert!(check(&format!("local/{id}"), image()).is_err());
734            assert!(check(&format!("local/{id}"), pdf()).is_err());
735        }
736        let wrong = row(
737            "local",
738            "different-model",
739            vec![Modality::Image],
740            CatalogSource::Live,
741        );
742        let transport = Transport::with_evidence(
743            WireProtocol::OpenAiChatCompletions,
744            true,
745            "local",
746            id,
747            false,
748            Some(&wrong),
749            None,
750        );
751        assert!(transport.permits(AttachmentKind::Image).is_err());
752    }
753
754    #[test]
755    fn cached_codex_revocation_overrides_astra_static_evidence_without_global_mutation() {
756        let fallback = catalog::codex_static_catalog_models()
757            .into_iter()
758            .find(|row| row.runtime_id() == ASTRA)
759            .unwrap();
760        for modalities in [vec![Modality::Text], vec![]] {
761            let cached = row(
762                "openai-codex",
763                "gpt-6-astra",
764                modalities,
765                CatalogSource::Live,
766            );
767            let transport = Transport::with_evidence(
768                WireProtocol::CodexResponses,
769                false,
770                "openai-codex",
771                "gpt-6-astra",
772                false,
773                Some(&cached),
774                Some(&fallback),
775            );
776            assert!(transport.permits(AttachmentKind::Image).is_err());
777            assert!(transport.permits(AttachmentKind::Pdf).is_err());
778        }
779    }
780
781    #[test]
782    fn supported_wire_protocols_require_separate_image_and_file_evidence() {
783        for wire in [
784            WireProtocol::OpenAiChatCompletions,
785            WireProtocol::OpenAiResponses,
786            WireProtocol::CodexResponses,
787        ] {
788            let cached = row(
789                "local",
790                "wire-evidence",
791                vec![Modality::Image],
792                CatalogSource::Live,
793            );
794            let transport = Transport::with_evidence(
795                wire,
796                false,
797                "local",
798                "wire-evidence",
799                false,
800                Some(&cached),
801                None,
802            );
803            assert!(transport.permits(AttachmentKind::Image).is_ok());
804            assert!(transport.permits(AttachmentKind::Pdf).is_err());
805            assert!(transport.permits(AttachmentKind::Text).is_ok());
806        }
807    }
808
809    #[test]
810    fn unsupported_cloud_gemini_extensions_reject_all_attachment_kinds() {
811        for model in [
812            "aws-bedrock/anthropic.claude-3-haiku",
813            "aws-bedrock/anthropic.claude-3-haiku#synaps-context=ctx-test",
814            "google-vertex/claude-sonnet-4-6",
815            "azure-openai/gpt-6-astra",
816            "google-gemini/gemini-2.5-pro",
817            "plugin:provider:model",
818        ] {
819            for block in [image(), pdf(), text("not even text documents")] {
820                assert_eq!(
821                    check(model, block.clone()).unwrap_err(),
822                    UNSUPPORTED_TRANSPORT,
823                    "{model}"
824                );
825                assert_eq!(
826                    validate_tool_blocks(model, &[block]).unwrap_err(),
827                    UNSUPPORTED_TRANSPORT,
828                    "{model}"
829                );
830            }
831            assert!(validate_messages(
832                model,
833                &[message(vec![json!({"type":"text","text":"ordinary text"})])]
834            )
835            .is_ok());
836        }
837    }
838
839    #[test]
840    fn invalid_base64_is_rejected_without_echoing_payload() {
841        for data in [
842            "",
843            "private-payload%%%",
844            "Zg",
845            "Zh==",
846            "Zg==\n",
847            "____",
848            "data:image/png;base64,Zg==",
849        ] {
850            let mut block = image();
851            block["source"]["data"] = json!(data);
852            assert_eq!(check(NATIVE, block).unwrap_err(), BAD_BASE64);
853        }
854    }
855
856    #[test]
857    fn corrupt_mismatched_and_out_of_bounds_images_are_rejected() {
858        let bytes = STANDARD.decode(PNG).unwrap();
859        let mut truncated = image();
860        truncated["source"]["data"] = json!(STANDARD.encode(&bytes[..bytes.len() - 1]));
861        assert!(check(NATIVE, truncated).unwrap_err().contains("corrupt"));
862        let mut mismatch = image();
863        mismatch["source"]["media_type"] = json!("image/jpeg");
864        assert!(check(NATIVE, mismatch)
865            .unwrap_err()
866            .contains("declared media type"));
867        for width in [0_u32, 8001] {
868            let mut invalid = bytes.clone();
869            invalid[16..20].copy_from_slice(&width.to_be_bytes());
870            let mut block = image();
871            block["source"]["data"] = json!(STANDARD.encode(invalid));
872            assert!(check(NATIVE, block).unwrap_err().contains("dimensions"));
873        }
874        for bytes in [
875            b"\x89PNG\r\n\x1a\n".as_slice(),
876            b"RIFF\0\0\0\0WEBP".as_slice(),
877        ] {
878            let mut block = image();
879            block["source"]["media_type"] =
880                json!(crate::tools::read::sniff_image_mime(bytes).unwrap());
881            block["source"]["data"] = json!(STANDARD.encode(bytes));
882            assert!(check(NATIVE, block).is_err());
883        }
884    }
885
886    #[test]
887    fn pdf_requires_matching_header_and_nearby_eof() {
888        for bytes in [
889            b"not a PDF %%EOF".to_vec(),
890            b"%PDF-1.7\ntruncated".to_vec(),
891            [b"%PDF-1.7\n%%EOF".as_slice(), &vec![b'x'; 1030]].concat(),
892        ] {
893            let mut block = pdf();
894            block["source"]["data"] = json!(STANDARD.encode(bytes));
895            assert!(check(NATIVE, block)
896                .unwrap_err()
897                .starts_with("PDF attachment is invalid"));
898        }
899    }
900
901    #[test]
902    fn canonical_sources_mime_and_titles_are_exact() {
903        for (key, value) in [
904            ("type", json!("url")),
905            ("media_type", json!("IMAGE/PNG")),
906            ("data", json!(null)),
907            ("url", json!("https://example.invalid/private")),
908            ("file_id", json!("secret-id")),
909        ] {
910            let mut block = image();
911            block["source"][key] = value;
912            assert_eq!(check(NATIVE, block).unwrap_err(), BAD_ATTACHMENT);
913        }
914        let mut titled_image = image();
915        titled_image["title"] = json!("image.png");
916        assert_eq!(check(NATIVE, titled_image).unwrap_err(), BAD_ATTACHMENT);
917        for title in [
918            "",
919            ".",
920            "..",
921            "../private.txt",
922            "a/b",
923            "a\\b",
924            "private\nname",
925        ] {
926            let mut block = text("private-payload");
927            block["title"] = json!(title);
928            assert_eq!(
929                check(NATIVE, block).unwrap_err(),
930                "Document attachment title must be a bounded filename"
931            );
932        }
933        let mut missing_title = text("private-payload");
934        missing_title.as_object_mut().unwrap().remove("title");
935        assert_eq!(check(NATIVE, missing_title).unwrap_err(), BAD_ATTACHMENT);
936        let mut long_title = text("x");
937        long_title["title"] = json!("x".repeat(MAX_TITLE_BYTES + 1));
938        assert!(check(NATIVE, long_title).is_err());
939        for mime in [
940            "text/html",
941            "application/octet-stream",
942            "text/plain; charset=utf-8",
943        ] {
944            let mut block = text("x");
945            block["source"]["media_type"] = json!(mime);
946            assert_eq!(check(NATIVE, block).unwrap_err(), BAD_ATTACHMENT);
947        }
948    }
949
950    #[test]
951    fn byte_bounds_cover_encoded_and_decoded_lengths_and_utf8() {
952        assert_eq!(MAX_IMAGE_BYTES, 3_670_016);
953        assert_eq!(MAX_PDF_BYTES, 10 * 1024 * 1024);
954        assert_eq!(MAX_TEXT_DOCUMENT_BYTES, 256 * 1024);
955        assert_eq!(MAX_HISTORY_ENCODED_BYTES, 20 * 1024 * 1024);
956        assert_eq!(MAX_BROKER_PROXY_BYTES, 2 * 1024 * 1024);
957        assert_eq!(
958            decode_bounded(&STANDARD.encode([0_u8; 2]), 2).unwrap(),
959            vec![0, 0]
960        );
961        // Three bytes fit the same encoded-length bound as two; the decoded
962        // bound must still reject them after decoding.
963        assert_eq!(
964            decode_bounded(&STANDARD.encode([0_u8; 3]), 2).unwrap_err(),
965            TOO_LARGE
966        );
967        for (mut block, limit) in [(image(), MAX_IMAGE_BYTES), (pdf(), MAX_PDF_BYTES)] {
968            block["source"]["data"] = json!("A".repeat(limit.div_ceil(3) * 4 + 4));
969            assert_eq!(check(NATIVE, block).unwrap_err(), TOO_LARGE);
970        }
971        let at_limit = "é".repeat(MAX_TEXT_DOCUMENT_BYTES / 2);
972        assert!(check(NATIVE, text(&at_limit)).is_ok());
973        assert_eq!(
974            check(NATIVE, text(&(at_limit + "é"))).unwrap_err(),
975            TOO_LARGE
976        );
977    }
978
979    #[test]
980    fn nested_tool_results_share_message_count_but_history_messages_do_not() {
981        let seven = vec![image(); MAX_ATTACHMENTS_PER_MESSAGE - 1];
982        let nested = json!({"type":"tool_result","tool_use_id":"read-1","content":seven});
983        assert!(validate_messages(NATIVE, &[message(vec![image(), nested.clone()])]).is_ok());
984        let error =
985            validate_messages(NATIVE, &[message(vec![image(), image(), nested])]).unwrap_err();
986        assert_eq!(
987            error,
988            "A message may contain at most 8 attachments, including tool media"
989        );
990        assert!(validate_tool_blocks(NATIVE, &vec![image(); MAX_ATTACHMENTS_PER_MESSAGE]).is_ok());
991        assert!(
992            validate_tool_blocks(NATIVE, &vec![image(); MAX_ATTACHMENTS_PER_MESSAGE + 1]).is_err()
993        );
994        let full = message(vec![image(); MAX_ATTACHMENTS_PER_MESSAGE]);
995        assert!(validate_messages(NATIVE, &[full.clone(), full]).is_ok());
996    }
997
998    #[test]
999    fn encoded_history_budget_covers_all_messages_and_json_escaping() {
1000        let full = message(vec![
1001            text(&"x".repeat(MAX_TEXT_DOCUMENT_BYTES));
1002            MAX_ATTACHMENTS_PER_MESSAGE
1003        ]);
1004        assert!(validate_messages(NATIVE, &vec![full.clone(); 9]).is_ok());
1005        assert_eq!(
1006            validate_messages(NATIVE, &vec![full; 10]).unwrap_err(),
1007            HISTORY_LIMIT
1008        );
1009        let escaped = text(&"\n".repeat(MAX_TEXT_DOCUMENT_BYTES));
1010        assert!(serialized_size(&escaped, MAX_TEXT_DOCUMENT_BYTES).is_none());
1011        let size = serialized_size(&escaped, usize::MAX).unwrap();
1012        assert_eq!(serialized_size(&escaped, size), Some(size));
1013        assert_eq!(serialized_size(&escaped, size - 1), None);
1014    }
1015
1016    #[test]
1017    fn broker_budget_counts_ordinary_history_and_keeps_envelope_reserve() {
1018        let full = text(&"x".repeat(MAX_TEXT_DOCUMENT_BYTES));
1019        assert!(validate_messages(
1020            "local/attachment-validator-budget-sa26",
1021            &[message(vec![full.clone(); 7])]
1022        )
1023        .is_ok());
1024        assert_eq!(
1025            validate_messages(
1026                "local/attachment-validator-budget-sa26",
1027                &[message(vec![full; 8])]
1028            )
1029            .unwrap_err(),
1030            BROKER_LIMIT
1031        );
1032        let history = [message(vec![
1033            text("small"),
1034            json!({"type":"text","text":"x".repeat(MAX_BROKER_PROXY_BYTES)}),
1035        ])];
1036        assert_eq!(
1037            validate_messages("local/attachment-validator-budget-sa26", &history).unwrap_err(),
1038            BROKER_LIMIT
1039        );
1040        assert!(validate_messages(NATIVE, &history).is_ok());
1041    }
1042
1043    #[test]
1044    fn malformed_media_containers_fail_but_opaque_tool_inputs_are_not_walked() {
1045        let assistant = Arc::new(json!({"role":"assistant","content":[image()]}));
1046        assert!(validate_messages(NATIVE, &[assistant]).is_err());
1047        let single = Arc::new(json!({"role":"user","content":image()}));
1048        assert!(validate_messages(NATIVE, &[single]).is_err());
1049        for block in [
1050            json!({"type":"image_url","image_url":{"url":"https://example.invalid/image"}}),
1051            json!({"type":"input_file","file_id":"private-id"}),
1052            json!({"type":"tool_result","content":image()}),
1053            json!({"type":"tool_result","content":[{"type":"tool_result","content":[image()]}]}),
1054            json!([image()]),
1055        ] {
1056            assert!(check(NATIVE, block).is_err());
1057        }
1058        let opaque = Arc::new(json!({"role":"assistant","content":[
1059            {"type":"tool_use","id":"t","name":"example","input":{"type":"image","source":{"data":"not media"}}},
1060            {"type":"thinking","thinking":"ordinary reasoning","signature":"opaque"},
1061            {"type":"provider_extension","value":"opaque"}
1062        ]}));
1063        assert!(validate_messages("plugin:provider:model", &[opaque]).is_ok());
1064        assert!(
1065            validate_tool_blocks(NATIVE, &[json!({"type":"tool_result","content":[image()]})])
1066                .is_err()
1067        );
1068    }
1069}