1use 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
22pub const DEFAULT_MAX_FILE_SIZE: u64 = 25 * 1024 * 1024;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct GazeReadOpts {
29 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
41pub 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#[derive(Debug)]
53#[non_exhaustive]
54pub struct GazeReadText {
55 descriptor: ToolDescriptor,
56}
57
58impl GazeReadText {
59 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#[derive(Debug)]
112#[non_exhaustive]
113pub struct GazeReadFile {
114 descriptor: ToolDescriptor,
115 max_file_size: u64,
116}
117
118impl GazeReadFile {
119 pub fn new() -> Self {
121 Self::with_max_file_size(DEFAULT_MAX_FILE_SIZE)
122 }
123
124 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 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 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
236fn restore_path(session: &gaze::Session, protected_path: &str) -> Result<String, ToolError> {
254 gaze::token_shape::validate_restore_shapes(protected_path)
257 .map_err(|_| ToolError::InvalidArgs("path restoration failed".into()))?;
258
259 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 #[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 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 ®istry,
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 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 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 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 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 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 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 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 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 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 ®istry,
954 &AllowAllAuth,
955 &manifest,
956 core.pipeline(),
957 &session,
958 core.locale_chain().as_slice(),
959 &policy,
960 );
961
962 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 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 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}