Skip to main content

gaze_document/mcp/
mod.rs

1//! MCP tool adapters for `gaze-document`.
2//!
3//! Tools in this module are opt-in via the `mcp` feature and must be
4//! registered explicitly by adopters. Invocation still happens through
5//! `gaze_mcp_core::PiiEnvelope::dispatch`; this module only provides the
6//! tool bodies and catalog metadata.
7
8use std::path::{Path, PathBuf};
9
10use async_trait::async_trait;
11use gaze_mcp_core::{
12    Tool, ToolCtx, ToolDescriptor, ToolError, ToolRegistry, ToolRegistryError, ToolResponse,
13};
14use serde::Serialize;
15use serde_json::json;
16
17#[cfg(feature = "ocr-tesseract")]
18use crate::extract::InputKind;
19#[cfg(feature = "ocr-tesseract")]
20use crate::DocumentError;
21
22/// Default `gaze_read_file` input cap: 25 MiB.
23pub const DEFAULT_MAX_FILE_SIZE: u64 = 25 * 1024 * 1024;
24
25/// Options used when registering `gaze-document` MCP tools.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct GazeReadOpts {
29    /// Maximum accepted input file size in bytes for `gaze_read_file`.
30    pub max_file_size: u64,
31}
32
33impl Default for GazeReadOpts {
34    fn default() -> Self {
35        Self {
36            max_file_size: DEFAULT_MAX_FILE_SIZE,
37        }
38    }
39}
40
41/// Register `gaze_read_text` and `gaze_read_file` with their canonical names.
42pub fn register_tools(
43    registry: &mut ToolRegistry,
44    opts: GazeReadOpts,
45) -> Result<(), ToolRegistryError> {
46    registry.register(GazeReadText::new())?;
47    registry.register(GazeReadFile::with_max_file_size(opts.max_file_size))?;
48    Ok(())
49}
50
51/// MCP tool that tokenizes already-extracted text via the document recognizer set.
52#[derive(Debug)]
53#[non_exhaustive]
54pub struct GazeReadText {
55    descriptor: ToolDescriptor,
56}
57
58impl GazeReadText {
59    /// Construct a `gaze_read_text` tool.
60    pub fn new() -> Self {
61        Self {
62            descriptor: ToolDescriptor::agent(
63                "gaze_read_text",
64                json!({
65                    "type": "object",
66                    "properties": {
67                        "text": {
68                            "type": "string",
69                            "description": "Already-extracted text to pseudonymize before model use."
70                        }
71                    },
72                    "required": ["text"]
73                }),
74            )
75            .with_description("Pseudonymize already-extracted text before returning it to an MCP client.")
76            .with_output_schema(response_schema())
77            .with_carriers(gaze_mcp_core::CarrierDeclaration::text_fields(&["text"]), response_carriers()),
78        }
79    }
80}
81
82impl Default for GazeReadText {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88#[async_trait]
89impl Tool for GazeReadText {
90    fn descriptor(&self) -> &ToolDescriptor {
91        &self.descriptor
92    }
93
94    async fn invoke(&self, ctx: &ToolCtx<'_>) -> Result<ToolResponse, ToolError> {
95        let text = required_string(ctx.redacted_args(), "text")?;
96        let clean_text = redact_document_text(text, ctx)?;
97        Ok(ToolResponse::json(json!(DocumentToolResponse {
98            clean_markdown: format_text_markdown(&clean_text),
99            manifest_id: ctx.call_id().to_string(),
100            file_metadata: FileMetadata {
101                source_kind: "text".to_string(),
102                ocr_mean_confidence: None,
103                bundle_version: crate::BUNDLE_VERSION,
104                page_count: None,
105            },
106        })))
107    }
108}
109
110/// MCP tool that OCRs an image/PDF file and tokenizes text before model use.
111#[derive(Debug)]
112#[non_exhaustive]
113pub struct GazeReadFile {
114    descriptor: ToolDescriptor,
115    max_file_size: u64,
116}
117
118impl GazeReadFile {
119    /// Construct a `gaze_read_file` tool with the default 25 MiB input cap.
120    pub fn new() -> Self {
121        Self::with_max_file_size(DEFAULT_MAX_FILE_SIZE)
122    }
123
124    /// Construct a `gaze_read_file` tool with a caller-supplied input cap.
125    pub fn with_max_file_size(max_file_size: u64) -> Self {
126        Self {
127            descriptor: ToolDescriptor::agent(
128                "gaze_read_file",
129                json!({
130                    "type": "object",
131                    "properties": {
132                        "path": {
133                            "type": "string",
134                            "description": "Filesystem path to a PNG, JPG, or PDF document."
135                        }
136                    },
137                    "required": ["path"]
138                }),
139            )
140            .with_description(
141                "Read an image or PDF through OCR and Gaze pseudonymization before MCP return.",
142            )
143            .with_output_schema(response_schema())
144            .with_carriers(
145                gaze_mcp_core::CarrierDeclaration::text_fields(&["path"]),
146                response_carriers(),
147            ),
148            max_file_size,
149        }
150    }
151}
152
153impl Default for GazeReadFile {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159#[async_trait]
160impl Tool for GazeReadFile {
161    fn descriptor(&self) -> &ToolDescriptor {
162        &self.descriptor
163    }
164
165    async fn invoke(&self, ctx: &ToolCtx<'_>) -> Result<ToolResponse, ToolError> {
166        // Restore only inside the trusted tool, after protected manifest args
167        // were recorded. Opening the token spelling can select a different file.
168        let protected_path = required_string(ctx.redacted_args(), "path")?;
169        let raw_path = restore_path(ctx.resources().session(), protected_path)?;
170        let path = PathBuf::from(raw_path);
171        validate_file(&path, self.max_file_size)?;
172        read_file_response(&path, ctx).map(|response| ToolResponse::json(json!(response)))
173    }
174}
175
176#[derive(Serialize)]
177struct DocumentToolResponse {
178    clean_markdown: String,
179    manifest_id: String,
180    file_metadata: FileMetadata,
181}
182
183#[derive(Serialize)]
184struct FileMetadata {
185    source_kind: String,
186    ocr_mean_confidence: Option<f32>,
187    bundle_version: u32,
188    page_count: Option<u32>,
189}
190
191fn required_string<'a>(args: &'a serde_json::Value, field: &str) -> Result<&'a str, ToolError> {
192    args.get(field)
193        .and_then(|value| value.as_str())
194        .ok_or_else(|| ToolError::InvalidArgs(format!("missing required string field `{field}`")))
195}
196
197fn response_carriers() -> gaze_mcp_core::CarrierDeclaration {
198    use gaze_mcp_core::CarrierSegment::Member;
199    let path = |names: &[&str]| {
200        names
201            .iter()
202            .map(|name| Member((*name).into()))
203            .collect::<Vec<_>>()
204    };
205    let mut members = ["clean_markdown", "manifest_id", "file_metadata"]
206        .iter()
207        .map(|name| path(&[name]))
208        .collect::<Vec<_>>();
209    for name in [
210        "source_kind",
211        "ocr_mean_confidence",
212        "bundle_version",
213        "page_count",
214    ] {
215        members.push(path(&["file_metadata", name]));
216    }
217    let numbers = ["ocr_mean_confidence", "bundle_version", "page_count"]
218        .iter()
219        .map(|name| path(&["file_metadata", name]))
220        .collect();
221    gaze_mcp_core::CarrierDeclaration::new(members, numbers)
222}
223
224fn redact_document_text(text: &str, ctx: &ToolCtx<'_>) -> Result<String, ToolError> {
225    let pipeline = crate::bundle::build_document_pipeline().map_err(map_document_error)?;
226    // The envelope may have already protected arguments. Preserve the session's
227    // exact owned tokens while applying the document-specific primary graph.
228    let mut transaction = ctx.resources().session().begin_transaction();
229    let clean = pipeline
230        .protect_text_transaction(&mut transaction, text, ctx.resources().protection_context())
231        .map_err(ToolError::internal)?;
232    transaction.commit().map_err(ToolError::internal)?;
233    Ok(clean)
234}
235
236/// Restore the `path` carrier of `gaze_read_file` to its raw form.
237///
238/// Arg protection only rewrites spans matched by the installed PII detectors
239/// and already-known session tokens; ordinary non-PII filenames such as
240/// `scan_1.png` pass through protection unchanged and are never registered as
241/// tokens. Unlike `Session::restore_strict_text`, which fails closed on *any*
242/// unowned token-shaped substring, this restores owned session tokens and only
243/// fails closed when:
244/// - the path contains a malformed token spelling (e.g. `<Email_>`,
245///   `<deadbeef:Email_>`), or
246/// - an unowned **non-bare** token spelling remains after restoration (e.g.
247///   an injected `<deadbeef:Email_999>`, `<Email_1>`, or `email_1`).
248///
249/// Broad bare `<word>_<digits>` identifiers that are not session-issued and are
250/// not built-in-class aliases (e.g. `scan_1`, `Scan_1`) are passed through to
251/// file validation, preserving the `restore(protect(path)) == path` round-trip
252/// for non-PII paths.
253fn restore_path(session: &gaze::Session, protected_path: &str) -> Result<String, ToolError> {
254    // Gate 1: reject malformed spellings before any substitution or filesystem
255    // access. Literal angle brackets beside a token are path text.
256    gaze::token_shape::validate_restore_shapes(protected_path)
257        .map_err(|_| ToolError::InvalidArgs("path restoration failed".into()))?;
258
259    // Gate 2: substitute known tokens and classify remaining shapes.
260    // Bare identifiers (e.g. `scan_1`) are audit-only; other unowned shapes
261    // (e.g. `<Email_1>`, `email_1`, `<deadbeef:Email_999>`) are rejected.
262    let assessment = session
263        .assess_restore_text(protected_path)
264        .map_err(|_| ToolError::InvalidArgs("path restoration failed".into()))?;
265    if !assessment.unknown_tokens().is_empty() {
266        return Err(ToolError::InvalidArgs("path restoration failed".into()));
267    }
268    Ok(assessment.into_restored().text)
269}
270
271fn validate_file(path: &Path, max_file_size: u64) -> Result<(), ToolError> {
272    let metadata = std::fs::metadata(path).map_err(|err| map_file_metadata_error(path, err))?;
273    if !metadata.is_file() {
274        return Err(ToolError::InvalidArgs(format!(
275            "path `{}` is not a regular file",
276            path.display()
277        )));
278    }
279    if metadata.len() > max_file_size {
280        return Err(ToolError::LimitExceeded(format!(
281            "file `{}` is {} bytes; configured cap is {} bytes",
282            path.display(),
283            metadata.len(),
284            max_file_size
285        )));
286    }
287    Ok(())
288}
289
290fn map_file_metadata_error(path: &Path, err: std::io::Error) -> ToolError {
291    if err.kind() == std::io::ErrorKind::NotFound {
292        ToolError::NotFound(format!("file `{}` not found", path.display()))
293    } else {
294        ToolError::internal(err)
295    }
296}
297
298#[cfg(feature = "ocr-tesseract")]
299fn read_file_response(path: &Path, ctx: &ToolCtx<'_>) -> Result<DocumentToolResponse, ToolError> {
300    let kind = InputKind::detect(path).map_err(map_document_error)?;
301    let backend = crate::ocr::TesseractBackend::new();
302    let (ocr_result, pdf_page_count, _) =
303        crate::bundle::run_ocr(path, kind, &backend).map_err(map_document_error)?;
304    let normalized = crate::ocr::normalize_ocr_artifacts(&ocr_result.text);
305    let clean_text = redact_document_text(&normalized, ctx)?;
306    Ok(DocumentToolResponse {
307        clean_markdown: crate::bundle::format_clean_markdown(&clean_text, kind),
308        manifest_id: ctx.call_id().to_string(),
309        file_metadata: FileMetadata {
310            source_kind: source_kind(kind).to_string(),
311            ocr_mean_confidence: ocr_result.mean_confidence,
312            bundle_version: crate::BUNDLE_VERSION,
313            page_count: pdf_page_count.and_then(|count| u32::try_from(count).ok()),
314        },
315    })
316}
317
318#[cfg(feature = "ocr-tesseract")]
319fn source_kind(kind: InputKind) -> &'static str {
320    match crate::bundle::kind_label(kind) {
321        "png" | "jpeg" => "image",
322        "pdf" => "pdf",
323        other => other,
324    }
325}
326
327#[cfg(feature = "ocr-tesseract")]
328fn map_document_error(err: DocumentError) -> ToolError {
329    match err {
330        DocumentError::TesseractNotFound(hint) | DocumentError::PdfiumNotFound(hint) => {
331            ToolError::BackendUnavailable(hint)
332        }
333        DocumentError::TesseractFailed { status, stderr } => {
334            ToolError::BackendFailure(format!("tesseract exited with status {status}: {stderr}"))
335        }
336        DocumentError::PdfRasterFailed(detail) => ToolError::BackendFailure(detail),
337        DocumentError::UnsupportedInput { path, reason } => {
338            ToolError::InvalidArgs(format!("unsupported input `{}`: {reason}", path.display()))
339        }
340        other => ToolError::internal(other),
341    }
342}
343
344fn format_text_markdown(text: &str) -> String {
345    let mut out = String::new();
346    out.push_str("# gaze-document safe text\n\n");
347    out.push_str("Source kind: `text`\n\n");
348    out.push_str("---\n\n");
349    out.push_str(text);
350    if !text.ends_with('\n') {
351        out.push('\n');
352    }
353    out
354}
355
356fn response_schema() -> serde_json::Value {
357    json!({
358        "type": "object",
359        "properties": {
360            "clean_markdown": { "type": "string" },
361            "manifest_id": { "type": "string" },
362            "file_metadata": {
363                "type": "object",
364                "properties": {
365                    "source_kind": { "type": "string" },
366                    "ocr_mean_confidence": { "type": ["number", "null"] },
367                    "bundle_version": { "type": "integer" },
368                    "page_count": { "type": ["integer", "null"] }
369                },
370                "required": [
371                    "source_kind",
372                    "ocr_mean_confidence",
373                    "bundle_version",
374                    "page_count"
375                ]
376            }
377        },
378        "required": ["clean_markdown", "manifest_id", "file_metadata"]
379    })
380}
381
382#[cfg(test)]
383mod tests {
384    use std::sync::atomic::{AtomicUsize, Ordering};
385    use std::sync::Arc;
386
387    use async_trait::async_trait;
388    use gaze_mcp_core::{
389        AuthError, AuthHook, DispatchError, ManifestStore, PiiEnvelope, Principal, SessionIdPolicy,
390    };
391    use gaze_mcp_core::{BeginCallContext, CallHandle, FailureReason, ManifestError, SnapshotRef};
392    use serde_json::json;
393
394    use super::*;
395
396    #[test]
397    fn w1_restore_path_rejects_malformed_session_placeholder() {
398        let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
399        assert!(
400            super::restore_path(&session, "directory/<deadbeef:Email_>/input.png").is_err(),
401            "malformed session placeholder reached filesystem path"
402        );
403    }
404
405    #[test]
406    fn r1_restore_path_preserves_owned_and_legacy_boundaries() {
407        let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
408        let token = session
409            .tokenize(&gaze::PiiClass::Email, "alice@example.invalid")
410            .unwrap();
411        assert_eq!(
412            super::restore_path(&session, &format!("directory/{token}/input.png")).unwrap(),
413            "directory/alice@example.invalid/input.png"
414        );
415        assert_eq!(
416            super::restore_path(&session, &format!("directory/<{token}>/input.png")).unwrap(),
417            "directory/<alice@example.invalid>/input.png"
418        );
419        for path in [
420            "directory/<<deadbeef:Email_1>>/input.png".to_owned(),
421            "directory/email1@gaze-fake.invalid/input.png".to_owned(),
422            "directory/<Email_999>/input.png".to_owned(),
423        ] {
424            assert!(
425                super::restore_path(&session, &path).is_err(),
426                "unowned token reached filesystem path: {path}"
427            );
428        }
429    }
430
431    /// A path carrying a safety-net redaction marker passes the restore gates as ordinary text.
432    ///
433    /// The marker is one-way output, not a token: restore must neither substitute it (that would
434    /// make the redaction reversible through the document tools) nor reject it as an unknown or
435    /// malformed token (that would refuse a legitimate protected path). It comes back verbatim,
436    /// alongside an owned token that DOES restore, so the pass-through is not an artefact of the
437    /// session holding nothing.
438    #[test]
439    fn restore_path_passes_a_redaction_marker_through_verbatim() {
440        let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
441        let token = session
442            .tokenize(&gaze::PiiClass::Email, "alice@example.invalid")
443            .unwrap();
444        for class in [
445            gaze::PiiClass::Name,
446            gaze::PiiClass::custom("address_2").unwrap(),
447        ] {
448            let marker = gaze::redaction_marker(&class);
449            let path = format!("directory/{marker}/{token}/input.png");
450            assert_eq!(
451                super::restore_path(&session, &path).unwrap(),
452                format!("directory/{marker}/alice@example.invalid/input.png"),
453                "{marker} must pass through untouched while the owned token restores"
454            );
455        }
456    }
457
458    #[test]
459    fn r1_restore_path_retains_malformed_family_rejection() {
460        let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
461        for path in [
462            "directory/<Custom:family:tenant-document_>/input.png",
463            "directory/<deadbeef:Custom:family:tenant-document_>/input.png",
464        ] {
465            assert!(session.restore_strict_text(path).is_err());
466            assert!(
467                super::restore_path(&session, path).is_err(),
468                "malformed family token reached filesystem path: {path}"
469            );
470        }
471    }
472
473    struct AllowAllAuth;
474
475    #[async_trait]
476    impl AuthHook for AllowAllAuth {
477        async fn authorize_agent(
478            &self,
479            _principal: &Principal,
480            _tool_name: &str,
481        ) -> Result<(), AuthError> {
482            Ok(())
483        }
484
485        async fn authorize_operator(
486            &self,
487            _principal: &Principal,
488            _tool_name: &str,
489        ) -> Result<(), AuthError> {
490            Err(AuthError::Denied("operator tier disabled in test".into()))
491        }
492    }
493
494    struct RecordingManifest {
495        begins: AtomicUsize,
496        finishes: AtomicUsize,
497        failures: AtomicUsize,
498        args: std::sync::Mutex<Vec<serde_json::Value>>,
499    }
500
501    impl RecordingManifest {
502        fn new() -> Self {
503            Self {
504                begins: AtomicUsize::new(0),
505                finishes: AtomicUsize::new(0),
506                failures: AtomicUsize::new(0),
507                args: Default::default(),
508            }
509        }
510    }
511
512    #[async_trait]
513    impl ManifestStore for RecordingManifest {
514        async fn begin_call(&self, ctx: BeginCallContext<'_>) -> Result<CallHandle, ManifestError> {
515            self.begins.fetch_add(1, Ordering::SeqCst);
516            self.args.lock().unwrap().push(ctx.redacted_args.clone());
517            Ok(CallHandle::new(ctx.call_id))
518        }
519
520        async fn finish_call(
521            &self,
522            _handle: CallHandle,
523            _snapshot: SnapshotRef,
524        ) -> Result<(), ManifestError> {
525            self.finishes.fetch_add(1, Ordering::SeqCst);
526            Ok(())
527        }
528
529        async fn fail_call(
530            &self,
531            _handle: CallHandle,
532            _reason: FailureReason,
533        ) -> Result<(), ManifestError> {
534            self.failures.fetch_add(1, Ordering::SeqCst);
535            Ok(())
536        }
537    }
538
539    struct Harness {
540        registry: ToolRegistry,
541        auth: AllowAllAuth,
542        manifest: Arc<RecordingManifest>,
543        pipeline: gaze::Pipeline,
544        session: gaze::Session,
545        session_id_policy: SessionIdPolicy,
546    }
547
548    impl Harness {
549        fn new() -> Self {
550            let mut registry = ToolRegistry::new();
551            register_tools(&mut registry, GazeReadOpts::default()).expect("register tools");
552            Self {
553                registry,
554                auth: AllowAllAuth,
555                manifest: Arc::new(RecordingManifest::new()),
556                pipeline: crate::bundle::build_document_pipeline().expect("pipeline"),
557                session: gaze::Session::new(gaze::Scope::Ephemeral).expect("session"),
558                session_id_policy: SessionIdPolicy::default_strict(),
559            }
560        }
561
562        async fn dispatch(
563            &self,
564            tool_name: &str,
565            args: serde_json::Value,
566        ) -> Result<serde_json::Value, DispatchError> {
567            let envelope = PiiEnvelope::new(
568                &self.registry,
569                &self.auth,
570                self.manifest.as_ref(),
571                &self.pipeline,
572                &self.session,
573                &[gaze::LocaleTag::Global],
574                &self.session_id_policy,
575            );
576            envelope
577                .dispatch(&Principal::new("unit-test"), tool_name, args, None)
578                .await
579                .map(|response| response.payload)
580        }
581    }
582
583    fn assert_no_raw_fixture_values(clean_markdown: &str) {
584        assert!(!clean_markdown.contains("Jane Doe"), "{clean_markdown}");
585        assert!(
586            !clean_markdown.contains("@example.invalid"),
587            "{clean_markdown}"
588        );
589        assert!(!clean_markdown.contains("555-0142"), "{clean_markdown}");
590    }
591
592    #[tokio::test]
593    async fn read_text_dispatch_returns_clean_markdown_and_manifest_id() {
594        let harness = Harness::new();
595        let payload = harness
596            .dispatch(
597                "gaze_read_text",
598                json!({
599                    "text": "Bill to: Jane Doe\nEmail: jane.doe@example.invalid\nPhone: +1-555-0142"
600                }),
601            )
602            .await
603            .expect("dispatch succeeds");
604
605        let clean_markdown = payload["clean_markdown"].as_str().expect("clean markdown");
606        assert!(clean_markdown.contains(":Email_"), "{clean_markdown}");
607        assert!(clean_markdown.contains(":Name_"), "{clean_markdown}");
608        assert!(
609            clean_markdown.contains(":Custom:phone_"),
610            "{clean_markdown}"
611        );
612        assert_no_raw_fixture_values(clean_markdown);
613        assert!(!payload["manifest_id"].as_str().unwrap().is_empty());
614        assert_eq!(payload["file_metadata"]["source_kind"], "text");
615        assert_eq!(
616            payload["file_metadata"]["ocr_mean_confidence"],
617            serde_json::Value::Null
618        );
619        assert_eq!(harness.manifest.begins.load(Ordering::SeqCst), 1);
620        assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 1);
621    }
622
623    #[tokio::test]
624    async fn read_file_missing_path_fails_closed_as_not_found() {
625        let harness = Harness::new();
626        let err = harness
627            .dispatch(
628                "gaze_read_file",
629                json!({ "path": "testdata/does-not-exist.png" }),
630            )
631            .await
632            .expect_err("missing file fails");
633
634        match err {
635            DispatchError::ToolError(ToolError::NotFound(message)) => {
636                assert!(message.contains("not found"));
637            }
638            other => panic!("unexpected error: {other:?}"),
639        }
640        assert_eq!(harness.manifest.failures.load(Ordering::SeqCst), 1);
641    }
642
643    #[tokio::test]
644    async fn read_file_limit_fails_closed_before_ocr() {
645        let mut registry = ToolRegistry::new();
646        registry
647            .register(GazeReadFile::with_max_file_size(1))
648            .expect("register file tool");
649        let harness = Harness {
650            registry,
651            auth: AllowAllAuth,
652            manifest: Arc::new(RecordingManifest::new()),
653            pipeline: crate::bundle::build_document_pipeline().expect("pipeline"),
654            session: gaze::Session::new(gaze::Scope::Ephemeral).expect("session"),
655            session_id_policy: SessionIdPolicy::default_strict(),
656        };
657        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
658            .join("testdata")
659            .join("synthetic_image.png");
660        let err = harness
661            .dispatch("gaze_read_file", json!({ "path": fixture }))
662            .await
663            .expect_err("oversized file fails");
664
665        match err {
666            DispatchError::ToolError(ToolError::LimitExceeded(message)) => {
667                assert!(message.contains("configured cap is 1 bytes"));
668            }
669            other => panic!("unexpected error: {other:?}"),
670        }
671    }
672
673    #[tokio::test]
674    async fn configured_dispatch_restores_owner_path_before_file_validation() {
675        let core = gaze_assembly::CorePipelineConfig::new().build().unwrap();
676        let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
677        let directory = tempfile::tempdir().unwrap();
678        let raw_path = directory
679            .path()
680            .join("alice@example.invalid")
681            .join("input.png");
682        std::fs::create_dir_all(raw_path.parent().unwrap()).unwrap();
683        std::fs::write(&raw_path, b"oversized").unwrap();
684        let gaze::CleanDocument::Text(protected_path) = core
685            .pseudonymize_text(&session, raw_path.to_str().unwrap())
686            .unwrap()
687        else {
688            panic!("text expected")
689        };
690        assert_ne!(protected_path, raw_path.to_str().unwrap());
691        // A distinct literal-token file must never replace the owner's target.
692        let literal_path = PathBuf::from(&protected_path);
693        assert!(literal_path.starts_with(directory.path()));
694        std::fs::create_dir_all(literal_path.parent().unwrap()).unwrap();
695        std::fs::write(&literal_path, b"").unwrap();
696        let mut registry = ToolRegistry::new();
697        registry
698            .register(GazeReadFile::with_max_file_size(1))
699            .unwrap();
700        let manifest = RecordingManifest::new();
701        let policy = SessionIdPolicy::default_strict();
702        let envelope = PiiEnvelope::new(
703            &registry,
704            &AllowAllAuth,
705            &manifest,
706            core.pipeline(),
707            &session,
708            core.locale_chain().as_slice(),
709            &policy,
710        );
711        for path in [raw_path.to_str().unwrap(), &protected_path] {
712            let err = envelope
713                .dispatch(
714                    &Principal::new("unit-test"),
715                    "gaze_read_file",
716                    json!({"path": path}),
717                    None,
718                )
719                .await
720                .unwrap_err();
721            assert!(
722                matches!(err, DispatchError::ToolError(ToolError::LimitExceeded(_))),
723                "{err:?}"
724            );
725        }
726        for args in manifest.args.lock().unwrap().iter() {
727            let path = args["path"].as_str().unwrap();
728            assert!(!path.contains("alice@example.invalid"));
729            assert_eq!(
730                session.restore_strict_text(path).unwrap(),
731                raw_path.to_str().unwrap()
732            );
733        }
734        let unknown_path = directory
735            .path()
736            .join("<deadbeef:Email_999>")
737            .join("input.png");
738        std::fs::create_dir_all(unknown_path.parent().unwrap()).unwrap();
739        std::fs::write(&unknown_path, b"oversized").unwrap();
740        let err = envelope
741            .dispatch(
742                &Principal::new("unit-test"),
743                "gaze_read_file",
744                json!({"path": unknown_path}),
745                None,
746            )
747            .await
748            .unwrap_err();
749        assert!(
750            matches!(err, DispatchError::ToolError(ToolError::InvalidArgs(_))),
751            "{err:?}"
752        );
753        // Ordinary bare `<word>_<digits>` filenames were previously rejected at
754        // the restore gate with `InvalidArgs`; they must now reach `validate_file`
755        // and surface `NotFound` for non-existent paths. Run one lowercase- and
756        // one capital-initial shape under the production-equivalent core rulepack
757        // (28 recognizers) to confirm none rewrites the shape before the gate.
758        for bare in ["scan_1.png", "Scan_1.png"] {
759            let path = directory.path().join(bare);
760            assert!(!path.exists(), "fixture `{bare}` must not exist");
761            let err = envelope
762                .dispatch(
763                    &Principal::new("unit-test"),
764                    "gaze_read_file",
765                    json!({ "path": path }),
766                    None,
767                )
768                .await
769                .unwrap_err();
770            assert!(
771                matches!(err, DispatchError::ToolError(ToolError::NotFound(_))),
772                "bare-shape `{bare}` must reach validate_file, got: {err:?}",
773            );
774        }
775        // Every dispatch in this test failed before `finish_call`.
776        assert_eq!(manifest.finishes.load(Ordering::SeqCst), 0);
777    }
778
779    #[tokio::test]
780    async fn read_file_dispatch_rejects_unowned_session_prefixed_token_in_path() {
781        let harness = Harness::new();
782        // The realistic attack vector — an injected unowned session-prefixed
783        // token that a different session's manifest authorizes — must still be
784        // blocked at the restore gate before any filesystem access.
785        let err = harness
786            .dispatch(
787                "gaze_read_file",
788                json!({ "path": "directory/<deadbeef:Email_999>/input.png" }),
789            )
790            .await
791            .expect_err("unowned session-prefixed token must fail at the restore gate");
792        match err {
793            DispatchError::ToolError(ToolError::InvalidArgs(message)) => {
794                assert_eq!(message, "path restoration failed");
795            }
796            other => panic!("unexpected error: {other:?}"),
797        }
798        assert_eq!(harness.manifest.failures.load(Ordering::SeqCst), 1);
799        assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
800    }
801
802    #[tokio::test]
803    async fn read_file_dispatch_passes_ordinary_bare_shape_filenames_to_validation() {
804        let harness = Harness::new();
805        let directory = tempfile::tempdir().unwrap();
806
807        // Ordinary filenames containing a bare `<word>_<digits>` shape were
808        // previously rejected at the restore gate with `InvalidArgs`. They must
809        // now reach `validate_file` and surface `NotFound` for non-existent
810        // paths. Covers the lowercase- and capital-initial regex alternations
811        // plus a long-ordinal shape; `doc_v2.png` is a near-miss (underscore
812        // followed by `v`, not a digit) that already worked and must not regress.
813        for bare in [
814            "scan_1.png",
815            "Scan_1.png",
816            "invoice_20250111.pdf",
817            "doc_v2.png",
818        ] {
819            let path = directory.path().join(bare);
820            assert!(!path.exists(), "fixture `{bare}` must not exist");
821            let err = harness
822                .dispatch("gaze_read_file", json!({ "path": path }))
823                .await
824                .expect_err("path must reach validate_file, not the restore gate");
825            match err {
826                DispatchError::ToolError(ToolError::NotFound(message)) => {
827                    assert!(
828                        message.contains(bare),
829                        "NotFound for `{bare}` should mention the filename: {message}"
830                    );
831                }
832                other => panic!("unexpected error for `{bare}`: {other:?}"),
833            }
834        }
835
836        // Every dispatch failed at `validate_file` (NotFound), none at the
837        // restore gate, so no call reached `finish_call`.
838        assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
839    }
840
841    #[tokio::test]
842    async fn read_file_dispatch_rejects_malformed_session_prefixed_token_in_path() {
843        let harness = Harness::new();
844        // `<deadbeef:Email_>` has a missing ordinal — the malformed-token gate
845        // must reject it before any filesystem access, regardless of whether a
846        // session-prefixed prefix is present.
847        for malformed in [
848            "directory/<deadbeef:Email_>/input.png",
849            "path/<Email_>/file.pdf",
850            "/tmp/<deadbeef:Name_>/doc.png",
851            "directory/<Custom:family:tenant-document_>/input.png",
852            "directory/<deadbeef:Custom:family:tenant-document_>/input.png",
853            "directory/<custom:family:tenant-document_>/input.png",
854        ] {
855            let err = harness
856                .dispatch("gaze_read_file", json!({ "path": malformed }))
857                .await
858                .expect_err("malformed token in path must fail at the restore gate");
859            assert!(
860                matches!(
861                    err,
862                    DispatchError::ToolError(ToolError::InvalidArgs(ref msg))
863                    if msg == "path restoration failed"
864                ),
865                "malformed `{malformed}` must return InvalidArgs, got: {err:?}",
866            );
867        }
868        assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
869    }
870
871    #[tokio::test]
872    async fn read_file_dispatch_rejects_unowned_token_inside_angle_brackets() {
873        let harness = Harness::new();
874        // Angle brackets around an unowned token are path text; the unowned
875        // token itself must still fail the restore gate before any filesystem
876        // access.
877        for unowned in [
878            "directory/<<deadbeef:Email_1>>/input.png",
879            "path/<<Email_1>>/file.pdf",
880        ] {
881            let err = harness
882                .dispatch("gaze_read_file", json!({ "path": unowned }))
883                .await
884                .expect_err("unowned token in path must fail at the restore gate");
885            assert!(
886                matches!(
887                    err,
888                    DispatchError::ToolError(ToolError::InvalidArgs(ref msg))
889                    if msg == "path restoration failed"
890                ),
891                "unowned `{unowned}` must return InvalidArgs, got: {err:?}",
892            );
893        }
894        assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
895    }
896
897    #[tokio::test]
898    async fn read_file_dispatch_rejects_unowned_wrapped_and_legacy_placeholders() {
899        let harness = Harness::new();
900        // Unowned wrapped (`<Email_1>`) and legacy (`email_1`) placeholders are
901        // not bare identifiers and must be rejected at the restore gate rather
902        // than passed through to filesystem access.
903        for unowned in [
904            "<Email_1>/input.png",
905            "directory/<Email_1>/input.png",
906            "email_1/input.png",
907        ] {
908            let err = harness
909                .dispatch("gaze_read_file", json!({ "path": unowned }))
910                .await
911                .expect_err("unowned trap placeholder in path must fail at the restore gate");
912            assert!(
913                matches!(
914                    err,
915                    DispatchError::ToolError(ToolError::InvalidArgs(ref msg))
916                    if msg == "path restoration failed"
917                ),
918                "unowned trap `{unowned}` must return InvalidArgs, got: {err:?}",
919            );
920        }
921        assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
922    }
923
924    #[tokio::test]
925    async fn read_file_dispatch_restores_owned_token_alongside_negative_cases() {
926        // Owned token must restore to raw PII; negative cases must still be
927        // blocked. This test covers both paths in the same session to confirm
928        // the assessment and malformed-spelling gates interact correctly.
929        let core = gaze_assembly::CorePipelineConfig::new().build().unwrap();
930        let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
931        let directory = tempfile::tempdir().unwrap();
932        let raw_path = directory
933            .path()
934            .join("alice@example.invalid")
935            .join("report.pdf");
936        std::fs::create_dir_all(raw_path.parent().unwrap()).unwrap();
937        std::fs::write(&raw_path, b"dummy").unwrap();
938        let gaze::CleanDocument::Text(protected_path) = core
939            .pseudonymize_text(&session, raw_path.to_str().unwrap())
940            .unwrap()
941        else {
942            panic!("text expected")
943        };
944        assert_ne!(protected_path, raw_path.to_str().unwrap());
945
946        let mut registry = ToolRegistry::new();
947        registry
948            .register(GazeReadFile::with_max_file_size(1))
949            .unwrap();
950        let manifest = RecordingManifest::new();
951        let policy = SessionIdPolicy::default_strict();
952        let envelope = PiiEnvelope::new(
953            &registry,
954            &AllowAllAuth,
955            &manifest,
956            core.pipeline(),
957            &session,
958            core.locale_chain().as_slice(),
959            &policy,
960        );
961
962        // Owned token restores and reaches validate_file (LimitExceeded).
963        let err = envelope
964            .dispatch(
965                &Principal::new("unit-test"),
966                "gaze_read_file",
967                json!({"path": protected_path}),
968                None,
969            )
970            .await
971            .unwrap_err();
972        assert!(
973            matches!(err, DispatchError::ToolError(ToolError::LimitExceeded(_))),
974            "owned-token path must reach validate_file: {err:?}",
975        );
976
977        // Malformed token in path is rejected at the restore gate.
978        let malformed = format!(
979            "{}/file.pdf",
980            directory.path().join("<deadbeef:Email_>").display()
981        );
982        let err = envelope
983            .dispatch(
984                &Principal::new("unit-test"),
985                "gaze_read_file",
986                json!({"path": malformed}),
987                None,
988            )
989            .await
990            .unwrap_err();
991        assert!(
992            matches!(err, DispatchError::ToolError(ToolError::InvalidArgs(_))),
993            "malformed token in path must be rejected: {err:?}",
994        );
995
996        // Unowned wrapped placeholder is rejected at the restore gate.
997        let unowned_wrapped = format!("{}/file.pdf", directory.path().join("<Email_1>").display());
998        let err = envelope
999            .dispatch(
1000                &Principal::new("unit-test"),
1001                "gaze_read_file",
1002                json!({"path": unowned_wrapped}),
1003                None,
1004            )
1005            .await
1006            .unwrap_err();
1007        assert!(
1008            matches!(err, DispatchError::ToolError(ToolError::InvalidArgs(_))),
1009            "unowned wrapped placeholder must be rejected: {err:?}",
1010        );
1011    }
1012
1013    #[cfg(feature = "ocr-tesseract")]
1014    #[tokio::test]
1015    async fn read_file_dispatch_returns_clean_markdown_for_fixture_when_backend_available() {
1016        let harness = Harness::new();
1017        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1018            .join("testdata")
1019            .join("synthetic_image.png");
1020        let payload = match harness
1021            .dispatch("gaze_read_file", json!({ "path": fixture }))
1022            .await
1023        {
1024            Ok(payload) => payload,
1025            Err(DispatchError::ToolError(ToolError::BackendUnavailable(message))) => {
1026                eprintln!("SKIP: document backend unavailable: {message}");
1027                return;
1028            }
1029            Err(other) => panic!("unexpected dispatch error: {other:?}"),
1030        };
1031
1032        let clean_markdown = payload["clean_markdown"].as_str().expect("clean markdown");
1033        assert!(clean_markdown.contains(":Email_"), "{clean_markdown}");
1034        assert!(clean_markdown.contains(":Name_"), "{clean_markdown}");
1035        assert!(
1036            clean_markdown.contains(":Custom:phone_"),
1037            "{clean_markdown}"
1038        );
1039        assert_no_raw_fixture_values(clean_markdown);
1040        assert_eq!(payload["file_metadata"]["source_kind"], "image");
1041        assert!(!payload["manifest_id"].as_str().unwrap().is_empty());
1042    }
1043}