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