Skip to main content

zeroclaw_tools/
image_info.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::fmt::Write;
4use std::sync::Arc;
5use zeroclaw_api::tool::{Tool, ToolResult};
6use zeroclaw_config::policy::SecurityPolicy;
7
8/// Upper bound on the image file size we will read for metadata extraction.
9///
10/// This is a coarse safety ceiling, not the multimodal size policy. The
11/// per-request decision on whether an image is small enough to inline for a
12/// vision model is the pipeline's `multimodal.max_image_size_mb`
13/// (`MultimodalConfig::effective_limits`, clamped to 1..=20 MB). We size this
14/// ceiling to that clamp's upper bound (20 MiB) so `image_info` never refuses
15/// to read — and therefore never silently withholds metadata for — a file the
16/// pipeline would otherwise have been configured to accept. When the pipeline
17/// limit is lower, the pipeline does the rejecting (with a model-facing note);
18/// `image_info` still returns the metadata text either way.
19const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
20
21/// Tool to read image metadata and expose the image to vision-capable models.
22///
23/// Extracts file size, format, and dimensions from header bytes, and emits an
24/// `[IMAGE:<absolute path>]` marker so the multimodal pipeline inlines the
25/// image bytes for the next provider call when the model supports vision.
26pub struct ImageInfoTool {
27    // Pre-canonicalization path-allowlist enforcement lives in the
28    // PathGuardedTool wrapper. The concrete tool still resolves raw tool
29    // paths and applies the read-side post-canonicalization boundary.
30    security: Arc<SecurityPolicy>,
31}
32
33impl ImageInfoTool {
34    pub fn new(security: Arc<SecurityPolicy>) -> Self {
35        Self { security }
36    }
37
38    /// Strip the Windows verbatim (`\\?\`) prefix that `canonicalize` prepends
39    /// on Windows, so the emitted `[IMAGE:]` marker carries a plain
40    /// drive-letter path (`C:\…`) instead of `\\?\C:\…`.
41    ///
42    /// This matters because the multimodal pipeline's path detector
43    /// (`zeroclaw-providers::multimodal::is_windows_path`) only recognizes
44    /// paths beginning with a drive letter; the leading backslashes of the
45    /// verbatim form make it reject the marker, so the image would never be
46    /// inlined for vision-capable models. The verbatim UNC form
47    /// (`\\?\UNC\server\share\…`) is unwrapped back to its `\\server\share\…`
48    /// spelling. Inputs without a verbatim prefix (e.g. all POSIX paths) are
49    /// returned unchanged and without allocating.
50    fn strip_windows_verbatim_prefix(path: &str) -> std::borrow::Cow<'_, str> {
51        if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
52            std::borrow::Cow::Owned(format!(r"\\{rest}"))
53        } else if let Some(rest) = path.strip_prefix(r"\\?\") {
54            std::borrow::Cow::Borrowed(rest)
55        } else {
56            std::borrow::Cow::Borrowed(path)
57        }
58    }
59
60    /// Detect image format from first few bytes (magic numbers).
61    fn detect_format(bytes: &[u8]) -> &'static str {
62        if bytes.len() < 4 {
63            return "unknown";
64        }
65        if bytes.starts_with(b"\x89PNG") {
66            "png"
67        } else if bytes.starts_with(b"\xFF\xD8\xFF") {
68            "jpeg"
69        } else if bytes.starts_with(b"GIF8") {
70            "gif"
71        } else if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
72            "webp"
73        } else if bytes.starts_with(b"BM") {
74            "bmp"
75        } else {
76            "unknown"
77        }
78    }
79
80    /// Try to extract dimensions from image header bytes.
81    /// Returns (width, height) if detectable.
82    fn extract_dimensions(bytes: &[u8], format: &str) -> Option<(u32, u32)> {
83        match format {
84            "png" => {
85                // PNG IHDR chunk: bytes 16-19 = width, 20-23 = height (big-endian)
86                if bytes.len() >= 24 {
87                    let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
88                    let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
89                    Some((w, h))
90                } else {
91                    None
92                }
93            }
94            "gif" => {
95                // GIF: bytes 6-7 = width, 8-9 = height (little-endian)
96                if bytes.len() >= 10 {
97                    let w = u32::from(u16::from_le_bytes([bytes[6], bytes[7]]));
98                    let h = u32::from(u16::from_le_bytes([bytes[8], bytes[9]]));
99                    Some((w, h))
100                } else {
101                    None
102                }
103            }
104            "bmp" => {
105                // BMP: bytes 18-21 = width, 22-25 = height (little-endian, signed)
106                if bytes.len() >= 26 {
107                    let w = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
108                    let h_raw = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
109                    let h = h_raw.unsigned_abs();
110                    Some((w, h))
111                } else {
112                    None
113                }
114            }
115            "jpeg" => Self::jpeg_dimensions(bytes),
116            _ => None,
117        }
118    }
119
120    /// Parse JPEG SOF markers to extract dimensions.
121    fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
122        let mut i = 2; // skip SOI marker
123        while i + 1 < bytes.len() {
124            if bytes[i] != 0xFF {
125                return None;
126            }
127            let marker = bytes[i + 1];
128            i += 2;
129
130            // SOF0..SOF3 markers contain dimensions
131            if (0xC0..=0xC3).contains(&marker) {
132                if i + 7 <= bytes.len() {
133                    let h = u32::from(u16::from_be_bytes([bytes[i + 3], bytes[i + 4]]));
134                    let w = u32::from(u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]));
135                    return Some((w, h));
136                }
137                return None;
138            }
139
140            // Skip this segment
141            if i + 1 < bytes.len() {
142                let seg_len = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize;
143                if seg_len < 2 {
144                    return None; // Malformed segment (valid segments have length >= 2)
145                }
146                i += seg_len;
147            } else {
148                return None;
149            }
150        }
151        None
152    }
153}
154
155#[async_trait]
156impl Tool for ImageInfoTool {
157    fn name(&self) -> &str {
158        "image_info"
159    }
160
161    fn description(&self) -> &str {
162        "Read image file metadata (format, dimensions, size). The image is also made available to vision-capable models via an inline image marker."
163    }
164
165    fn parameters_schema(&self) -> serde_json::Value {
166        json!({
167            "type": "object",
168            "properties": {
169                "path": {
170                    "type": "string",
171                    "description": "Path to the image file (absolute or relative to workspace)"
172                }
173            },
174            "required": ["path"]
175        })
176    }
177
178    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
179        let path_str = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
180            ::zeroclaw_log::record!(
181                WARN,
182                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
183                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
184                    .with_attrs(::serde_json::json!({"param": "path"})),
185                "image_info: missing path parameter"
186            );
187            anyhow::Error::msg("Missing 'path' parameter")
188        })?;
189
190        // Path-allowlist checks are applied by the PathGuardedTool wrapper at
191        // registration time (see zeroclaw-runtime::tools::mod). Successful
192        // reads consume budget through RateLimitedTool; post-wrapper
193        // canonicalize failures are charged here so missing-file probes are not
194        // free.
195
196        let full_path = self.security.resolve_tool_path(path_str);
197        let resolved_path = match tokio::fs::canonicalize(&full_path).await {
198            Ok(path) => path,
199            Err(e) => {
200                let _ = self.security.record_action();
201                let error = if e.kind() == std::io::ErrorKind::NotFound {
202                    format!("File not found: {path_str}")
203                } else {
204                    format!("Failed to resolve file path: {e}")
205                };
206                return Ok(ToolResult {
207                    success: false,
208                    output: String::new(),
209                    error: Some(error),
210                });
211            }
212        };
213
214        if !self.security.is_resolved_path_readable(&resolved_path) {
215            return Ok(ToolResult {
216                success: false,
217                output: String::new(),
218                error: Some(
219                    "Resolved image path is outside the allowed readable roots.".to_string(),
220                ),
221            });
222        }
223
224        let metadata = tokio::fs::metadata(&resolved_path).await.map_err(|e| {
225            ::zeroclaw_log::record!(
226                ERROR,
227                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
228                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
229                    .with_attrs(::serde_json::json!({
230                        "path": path_str,
231                        "error": format!("{}", e),
232                    })),
233                "image_info: failed to read file metadata"
234            );
235            anyhow::Error::msg(format!("Failed to read file metadata: {e}"))
236        })?;
237
238        let file_size = metadata.len();
239
240        if file_size > MAX_IMAGE_BYTES {
241            return Ok(ToolResult {
242                success: false,
243                output: String::new(),
244                error: Some(format!(
245                    "Image too large: {file_size} bytes (max {MAX_IMAGE_BYTES} bytes)"
246                )),
247            });
248        }
249
250        let bytes = tokio::fs::read(&resolved_path).await.map_err(|e| {
251            ::zeroclaw_log::record!(
252                ERROR,
253                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
254                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
255                    .with_attrs(::serde_json::json!({
256                        "path": path_str,
257                        "error": format!("{}", e),
258                    })),
259                "image_info: failed to read image file"
260            );
261            anyhow::Error::msg(format!("Failed to read image file: {e}"))
262        })?;
263
264        let format = Self::detect_format(&bytes);
265        let dimensions = Self::extract_dimensions(&bytes, format);
266
267        // We emit two things for the resolved image:
268        //   1. A durable `File: <absolute path>` line, and
269        //   2. A standalone `[IMAGE:<absolute path>]` marker
270        // both using the canonicalized absolute path (not the caller-supplied
271        // `path_str`, which may be workspace-relative — the tool-result marker
272        // promoter only recognizes absolute paths, so a relative path would be
273        // silently dropped and never reach the model; see issue #7436).
274        //
275        // The `[IMAGE:]` marker is what the multimodal pipeline inlines for
276        // vision models, but it is stripped from older turns to control
277        // context size. The separate `File:` line keeps the path visible in
278        // history *after* the marker is gone, so the model retains the path
279        // (and can re-read the file via `image_info`) across turns. Emitting
280        // the same path twice is safe: the promoter
281        // (`canonicalize_tool_result_media_markers`) dedups a bare path that
282        // already appears inside an explicit marker, so the `File:` line is
283        // not wrapped into a second, double-counted marker.
284        //
285        // On Windows `canonicalize` returns a verbatim path (`\\?\C:\…`); we
286        // strip that prefix so both the `File:` line and the marker carry a
287        // plain `C:\…` path the multimodal pipeline's `is_windows_path`
288        // detector accepts. Using the identical string for both also keeps the
289        // promoter's dedup exact. See #7436 (Windows follow-up to #7446).
290        let resolved_display = resolved_path.display().to_string();
291        let marker_path = Self::strip_windows_verbatim_prefix(&resolved_display);
292        let mut output = format!("File: {marker_path}\nFormat: {format}\nSize: {file_size} bytes");
293
294        if let Some((w, h)) = dimensions {
295            let _ = write!(output, "\nDimensions: {w}x{h}");
296        }
297
298        let _ = write!(output, "\n[IMAGE:{marker_path}]");
299
300        Ok(ToolResult {
301            success: true,
302            output,
303            error: None,
304        })
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::wrappers::{PathGuardedTool, RateLimitedTool};
312    use std::path::{Component, Path, PathBuf};
313    use tempfile::TempDir;
314    use zeroclaw_config::autonomy::AutonomyLevel;
315    use zeroclaw_config::policy::SecurityPolicy;
316
317    const MINIMAL_PNG: &[u8] = &[
318        0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
319        0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
320        0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8,
321        0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00,
322        0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
323    ];
324
325    fn test_security() -> Arc<SecurityPolicy> {
326        Arc::new(SecurityPolicy {
327            autonomy: AutonomyLevel::Full,
328            workspace_dir: std::env::temp_dir(),
329            workspace_only: false,
330            forbidden_paths: vec![],
331            ..SecurityPolicy::default()
332        })
333    }
334
335    /// Security policy with `workspace_only: true` so external absolute paths
336    /// are blocked by the `PathGuardedTool` wrapper.
337    fn workspace_security(workspace: std::path::PathBuf) -> Arc<SecurityPolicy> {
338        Arc::new(SecurityPolicy {
339            autonomy: AutonomyLevel::Full,
340            workspace_dir: workspace,
341            workspace_only: true,
342            ..SecurityPolicy::default()
343        })
344    }
345
346    fn rootless_path(path: &Path) -> PathBuf {
347        let mut relative = PathBuf::new();
348        for component in path.components() {
349            match component {
350                Component::Prefix(_) | Component::RootDir | Component::CurDir => {}
351                Component::ParentDir => panic!("test path must not contain parent components"),
352                Component::Normal(part) => relative.push(part),
353            }
354        }
355        relative
356    }
357
358    /// Wraps `ImageInfoTool` with the production `PathGuardedTool` +
359    /// `RateLimitedTool` stack, mirroring the registration in
360    /// `zeroclaw-runtime::tools::mod`.  Use this in tests that exercise
361    /// path-allowlist or rate-limit behavior.
362    fn wrapped_tool(workspace: std::path::PathBuf) -> Box<dyn Tool> {
363        let security = workspace_security(workspace);
364        wrapped_tool_with_security(security)
365    }
366
367    fn wrapped_tool_with_security(security: Arc<SecurityPolicy>) -> Box<dyn Tool> {
368        Box::new(RateLimitedTool::new(
369            PathGuardedTool::new(ImageInfoTool::new(security.clone()), security.clone()),
370            security,
371        ))
372    }
373
374    #[test]
375    fn image_info_tool_name() {
376        let tool = ImageInfoTool::new(test_security());
377        assert_eq!(tool.name(), "image_info");
378    }
379
380    #[test]
381    fn image_info_tool_description() {
382        let tool = ImageInfoTool::new(test_security());
383        assert!(!tool.description().is_empty());
384        assert!(tool.description().contains("image"));
385    }
386
387    #[test]
388    fn image_info_tool_schema() {
389        let tool = ImageInfoTool::new(test_security());
390        let schema = tool.parameters_schema();
391        assert!(schema["properties"]["path"].is_object());
392        // `include_base64` was removed: the image now reaches vision models via
393        // an inline `[IMAGE:]` marker, not a bare base64 blob (issue #7436).
394        assert!(schema["properties"]["include_base64"].is_null());
395        let required = schema["required"].as_array().unwrap();
396        assert!(required.contains(&json!("path")));
397    }
398
399    #[test]
400    fn image_info_tool_spec() {
401        let tool = ImageInfoTool::new(test_security());
402        let spec = tool.spec();
403        assert_eq!(spec.name, "image_info");
404        assert!(spec.parameters.is_object());
405    }
406
407    // ── Windows verbatim-prefix stripping ───────────────────────
408
409    #[test]
410    fn strip_verbatim_disk_prefix() {
411        // `canonicalize` on Windows yields `\\?\C:\…`; the marker must carry
412        // the plain drive-letter path so `is_windows_path` accepts it.
413        assert_eq!(
414            ImageInfoTool::strip_windows_verbatim_prefix(r"\\?\C:\Users\me\Downloads\a.png"),
415            r"C:\Users\me\Downloads\a.png"
416        );
417    }
418
419    #[test]
420    fn strip_verbatim_unc_prefix() {
421        // Verbatim UNC unwraps back to the `\\server\share\…` spelling.
422        assert_eq!(
423            ImageInfoTool::strip_windows_verbatim_prefix(r"\\?\UNC\server\share\pic.png"),
424            r"\\server\share\pic.png"
425        );
426    }
427
428    #[test]
429    fn strip_verbatim_prefix_leaves_plain_paths_unchanged() {
430        // POSIX paths and already-plain Windows paths must pass through
431        // untouched (and without allocating).
432        for input in [
433            "/home/me/pictures/a.png",
434            r"C:\Users\me\a.png",
435            "relative/a.png",
436        ] {
437            assert!(matches!(
438                ImageInfoTool::strip_windows_verbatim_prefix(input),
439                std::borrow::Cow::Borrowed(_)
440            ));
441            assert_eq!(ImageInfoTool::strip_windows_verbatim_prefix(input), input);
442        }
443    }
444
445    // ── Format detection ────────────────────────────────────────
446
447    #[test]
448    fn detect_png() {
449        let bytes = b"\x89PNG\r\n\x1a\n";
450        assert_eq!(ImageInfoTool::detect_format(bytes), "png");
451    }
452
453    #[test]
454    fn detect_jpeg() {
455        let bytes = b"\xFF\xD8\xFF\xE0";
456        assert_eq!(ImageInfoTool::detect_format(bytes), "jpeg");
457    }
458
459    #[test]
460    fn detect_gif() {
461        let bytes = b"GIF89a";
462        assert_eq!(ImageInfoTool::detect_format(bytes), "gif");
463    }
464
465    #[test]
466    fn detect_webp() {
467        let bytes = b"RIFF\x00\x00\x00\x00WEBP";
468        assert_eq!(ImageInfoTool::detect_format(bytes), "webp");
469    }
470
471    #[test]
472    fn detect_bmp() {
473        let bytes = b"BM\x00\x00";
474        assert_eq!(ImageInfoTool::detect_format(bytes), "bmp");
475    }
476
477    #[test]
478    fn detect_unknown_short() {
479        let bytes = b"\x00\x01";
480        assert_eq!(ImageInfoTool::detect_format(bytes), "unknown");
481    }
482
483    #[test]
484    fn detect_unknown_garbage() {
485        let bytes = b"this is not an image";
486        assert_eq!(ImageInfoTool::detect_format(bytes), "unknown");
487    }
488
489    // ── Dimension extraction ────────────────────────────────────
490
491    #[test]
492    fn png_dimensions() {
493        // Minimal PNG IHDR: 8-byte signature + 4-byte length + 4-byte IHDR + 4-byte width + 4-byte height
494        let mut bytes = vec![
495            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
496            0x00, 0x00, 0x00, 0x0D, // IHDR length
497            0x49, 0x48, 0x44, 0x52, // "IHDR"
498            0x00, 0x00, 0x03, 0x20, // width: 800
499            0x00, 0x00, 0x02, 0x58, // height: 600
500        ];
501        bytes.extend_from_slice(&[0u8; 10]); // padding
502        let dims = ImageInfoTool::extract_dimensions(&bytes, "png");
503        assert_eq!(dims, Some((800, 600)));
504    }
505
506    #[test]
507    fn gif_dimensions() {
508        let bytes = [
509            0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
510            0x40, 0x01, // width: 320 (LE)
511            0xF0, 0x00, // height: 240 (LE)
512        ];
513        let dims = ImageInfoTool::extract_dimensions(&bytes, "gif");
514        assert_eq!(dims, Some((320, 240)));
515    }
516
517    #[test]
518    fn bmp_dimensions() {
519        let mut bytes = vec![0u8; 26];
520        bytes[0] = b'B';
521        bytes[1] = b'M';
522        // width at offset 18 (LE): 1024
523        bytes[18] = 0x00;
524        bytes[19] = 0x04;
525        bytes[20] = 0x00;
526        bytes[21] = 0x00;
527        // height at offset 22 (LE): 768
528        bytes[22] = 0x00;
529        bytes[23] = 0x03;
530        bytes[24] = 0x00;
531        bytes[25] = 0x00;
532        let dims = ImageInfoTool::extract_dimensions(&bytes, "bmp");
533        assert_eq!(dims, Some((1024, 768)));
534    }
535
536    #[test]
537    fn jpeg_dimensions() {
538        // Minimal JPEG-like byte sequence with SOF0 marker
539        let mut bytes: Vec<u8> = vec![
540            0xFF, 0xD8, // SOI
541            0xFF, 0xE0, // APP0 marker
542            0x00, 0x10, // APP0 length = 16
543        ];
544        bytes.extend_from_slice(&[0u8; 14]); // APP0 payload
545        bytes.extend_from_slice(&[
546            0xFF, 0xC0, // SOF0 marker
547            0x00, 0x11, // SOF0 length
548            0x08, // precision
549            0x01, 0xE0, // height: 480
550            0x02, 0x80, // width: 640
551        ]);
552        let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg");
553        assert_eq!(dims, Some((640, 480)));
554    }
555
556    #[test]
557    fn jpeg_malformed_zero_length_segment() {
558        // Zero-length segment should return None instead of looping forever
559        let bytes: Vec<u8> = vec![
560            0xFF, 0xD8, // SOI
561            0xFF, 0xE0, // APP0 marker
562            0x00, 0x00, // length = 0 (malformed)
563        ];
564        let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg");
565        assert!(dims.is_none());
566    }
567
568    #[test]
569    fn unknown_format_no_dimensions() {
570        let bytes = b"random data here";
571        let dims = ImageInfoTool::extract_dimensions(bytes, "unknown");
572        assert!(dims.is_none());
573    }
574
575    // ── Execute tests ───────────────────────────────────────────
576
577    #[tokio::test]
578    async fn execute_missing_path() {
579        let tool = ImageInfoTool::new(test_security());
580        let result = tool.execute(json!({})).await;
581        assert!(result.is_err());
582    }
583
584    #[tokio::test]
585    async fn execute_nonexistent_file() {
586        let tool = ImageInfoTool::new(test_security());
587        let result = tool
588            .execute(json!({"path": "/tmp/nonexistent_image_xyz.png"}))
589            .await
590            .unwrap();
591        assert!(!result.success);
592        assert!(result.error.as_ref().unwrap().contains("not found"));
593    }
594
595    #[tokio::test]
596    async fn execute_real_file() {
597        let dir = TempDir::new().unwrap();
598        let png_path = dir.path().join("test.png");
599        tokio::fs::write(&png_path, MINIMAL_PNG).await.unwrap();
600
601        let tool = ImageInfoTool::new(test_security());
602        let result = tool
603            .execute(json!({"path": png_path.to_string_lossy()}))
604            .await
605            .unwrap();
606        assert!(result.success);
607        assert!(result.output.contains("Format: png"));
608        assert!(result.output.contains("Dimensions: 1x1"));
609        assert!(!result.output.contains("data:"));
610        // The output carries an absolute-path [IMAGE:] marker so the
611        // multimodal pipeline can inline the image for vision models.
612        let canonical = tokio::fs::canonicalize(&png_path).await.unwrap();
613        assert!(
614            result
615                .output
616                .contains(&format!("[IMAGE:{}]", canonical.display())),
617            "expected absolute-path image marker, got: {}",
618            result.output
619        );
620    }
621
622    #[tokio::test]
623    async fn wrapped_blocks_external_absolute_path() {
624        // Regression for the removed inline path check: when ImageInfoTool is
625        // composed with PathGuardedTool (as it is in production), an external
626        // absolute path must be blocked before the inner tool runs.
627        let workspace = std::env::temp_dir().join("zeroclaw_image_info_wrap");
628        let _ = std::fs::create_dir_all(&workspace);
629        let tool = wrapped_tool(workspace);
630
631        #[cfg(unix)]
632        let target = "/etc/passwd";
633        #[cfg(windows)]
634        let target = {
635            let sysroot = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
636            format!(r"{sysroot}\System32\drivers\etc\hosts")
637        };
638
639        let result = tool.execute(json!({"path": target})).await.unwrap();
640        assert!(!result.success, "external path must be blocked");
641        assert!(
642            result
643                .error
644                .as_deref()
645                .unwrap_or("")
646                .contains("Path blocked"),
647            "expected 'Path blocked' error, got: {:?}",
648            result.error
649        );
650    }
651
652    #[tokio::test]
653    async fn wrapped_blocks_path_traversal() {
654        // Path-traversal under workspace_only must be blocked by the wrapper,
655        // not pass through to the inner tool.
656        let workspace = std::env::temp_dir().join("zeroclaw_image_info_trav");
657        let _ = std::fs::create_dir_all(&workspace);
658        let tool = wrapped_tool(workspace);
659
660        let result = tool
661            .execute(json!({"path": "../../../etc/passwd"}))
662            .await
663            .unwrap();
664        assert!(!result.success, "path traversal must be blocked");
665        assert!(
666            result
667                .error
668                .as_deref()
669                .unwrap_or("")
670                .contains("Path blocked"),
671            "expected 'Path blocked' error, got: {:?}",
672            result.error
673        );
674    }
675
676    #[tokio::test]
677    async fn wrapped_normalizes_workspace_prefixed_relative_path() {
678        let root = TempDir::new().unwrap();
679        let workspace = root.path().join("zeroclaw-data").join("workspace");
680        let images_dir = workspace.join("images");
681        tokio::fs::create_dir_all(&images_dir).await.unwrap();
682
683        let png_path = images_dir.join("one.png");
684        tokio::fs::write(&png_path, MINIMAL_PNG).await.unwrap();
685
686        let workspace_prefixed = rootless_path(&workspace).join("images").join("one.png");
687        let tool = wrapped_tool(workspace);
688
689        let result = tool
690            .execute(json!({"path": workspace_prefixed.to_string_lossy()}))
691            .await
692            .unwrap();
693
694        assert!(
695            result.success,
696            "workspace-prefixed image path should resolve through security policy, error: {:?}",
697            result.error
698        );
699        assert!(result.output.contains("Format: png"));
700        // Regression for issue #7436: a workspace-relative path must still be
701        // emitted as an absolute-path [IMAGE:] marker. Before the fix the tool
702        // echoed the relative input, which the marker promoter (anchored on a
703        // leading `/`) silently dropped, so the image never reached the model.
704        let canonical = tokio::fs::canonicalize(&png_path).await.unwrap();
705        assert!(
706            result
707                .output
708                .contains(&format!("[IMAGE:{}]", canonical.display())),
709            "expected absolute-path image marker, got: {}",
710            result.output
711        );
712        assert!(
713            canonical.is_absolute(),
714            "marker path must be absolute so the multimodal pipeline can load it"
715        );
716    }
717
718    #[cfg(unix)]
719    #[tokio::test]
720    async fn wrapped_blocks_symlink_escape_after_resolution() {
721        use std::os::unix::fs::symlink;
722
723        let root = TempDir::new().unwrap();
724        let workspace = root.path().join("workspace");
725        let outside = root.path().join("outside");
726        tokio::fs::create_dir_all(&workspace).await.unwrap();
727        tokio::fs::create_dir_all(&outside).await.unwrap();
728        tokio::fs::write(outside.join("secret.png"), MINIMAL_PNG)
729            .await
730            .unwrap();
731        symlink(outside.join("secret.png"), workspace.join("link.png")).unwrap();
732
733        let tool = wrapped_tool(workspace);
734        let result = tool.execute(json!({"path": "link.png"})).await.unwrap();
735
736        assert!(!result.success, "symlink escape must be blocked");
737        let error = result.error.as_deref().unwrap_or("");
738        assert!(
739            error.contains("outside the allowed readable roots"),
740            "expected readable-roots error, got: {:?}",
741            error
742        );
743        assert!(
744            !error.contains(&outside.to_string_lossy().to_string()),
745            "policy error must not disclose resolved outside path, got: {error}"
746        );
747    }
748
749    #[tokio::test]
750    async fn wrapped_blocks_write_only_allowed_root_read() {
751        let root = TempDir::new().unwrap();
752        let workspace = root.path().join("workspace");
753        let write_only = root.path().join("write-only");
754        tokio::fs::create_dir_all(&workspace).await.unwrap();
755        tokio::fs::create_dir_all(&write_only).await.unwrap();
756        let png_path = write_only.join("one.png");
757        tokio::fs::write(&png_path, MINIMAL_PNG).await.unwrap();
758
759        let security = Arc::new(SecurityPolicy {
760            autonomy: AutonomyLevel::Full,
761            workspace_dir: workspace,
762            workspace_only: true,
763            allowed_roots_write_only: vec![write_only],
764            ..SecurityPolicy::default()
765        });
766        let tool = wrapped_tool_with_security(security);
767        let result = tool
768            .execute(json!({"path": png_path.to_string_lossy()}))
769            .await
770            .unwrap();
771
772        assert!(!result.success, "write-only root must not be readable");
773        assert!(
774            result
775                .error
776                .as_deref()
777                .unwrap_or("")
778                .contains("outside the allowed readable roots"),
779            "expected readable-roots error, got: {:?}",
780            result.error
781        );
782    }
783
784    #[tokio::test]
785    async fn missing_file_probe_consumes_action_budget() {
786        let root = TempDir::new().unwrap();
787        let security = Arc::new(SecurityPolicy {
788            autonomy: AutonomyLevel::Full,
789            workspace_dir: root.path().to_path_buf(),
790            workspace_only: true,
791            max_actions_per_hour: 1,
792            ..SecurityPolicy::default()
793        });
794        let tool = ImageInfoTool::new(security.clone());
795
796        assert!(!security.is_rate_limited());
797        let result = tool.execute(json!({"path": "missing.png"})).await.unwrap();
798
799        assert!(!result.success);
800        assert!(security.is_rate_limited());
801    }
802
803    #[tokio::test]
804    async fn emits_inline_image_marker_with_absolute_path() {
805        // The image must be exposed to vision models via an [IMAGE:] marker
806        // carrying the canonical absolute path, regardless of how the caller
807        // spelled the input path (issue #7436).
808        let dir = TempDir::new().unwrap();
809        let png_path = dir.path().join("marker.png");
810        tokio::fs::write(&png_path, MINIMAL_PNG).await.unwrap();
811
812        let tool = ImageInfoTool::new(test_security());
813        let result = tool
814            .execute(json!({"path": png_path.to_string_lossy()}))
815            .await
816            .unwrap();
817
818        assert!(result.success);
819        let canonical = tokio::fs::canonicalize(&png_path).await.unwrap();
820        assert!(
821            result
822                .output
823                .contains(&format!("[IMAGE:{}]", canonical.display())),
824            "expected absolute-path image marker, got: {}",
825            result.output
826        );
827        // No bare base64 blob should leak into the text output anymore.
828        assert!(!result.output.contains("base64,"));
829    }
830}