From de924af134830fda85aade370be40c92a9817f8d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 5 May 2026 16:59:05 +0200 Subject: [PATCH] memories-mcp: hide dot paths from list, read, and search (#21201) ## Why The local memories root can contain implementation details such as `.git` plus incidental OS metadata like `.DS_Store`. Those entries are not authored memory content, so the memories MCP should keep them invisible instead of exposing them through normal discovery or direct lookup. Only for local implementation ofc ## What changed - Return `NotFound` for scoped `list`, `read`, and `search` requests that include a hidden path component. - Skip hidden files and directories while listing a directory or recursively searching the memories tree. - Add regression coverage for hidden files, hidden directories, and hidden scoped requests across `list`, `read`, and `search`. ## Testing - Added focused regression tests in `memories/mcp/src/local_tests.rs` covering hidden-path behavior across the affected APIs. --- codex-rs/memories/mcp/src/local.rs | 23 ++++++ codex-rs/memories/mcp/src/local_tests.rs | 98 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/codex-rs/memories/mcp/src/local.rs b/codex-rs/memories/mcp/src/local.rs index 7ceb587dc..0f642d43f 100644 --- a/codex-rs/memories/mcp/src/local.rs +++ b/codex-rs/memories/mcp/src/local.rs @@ -58,6 +58,11 @@ impl LocalMemoriesBackend { "must stay within the memories root", )); } + if relative.components().any(is_hidden_component) { + return Err(MemoriesBackendError::NotFound { + path: relative_path.to_string(), + }); + } let components = relative.components().collect::>(); let mut scoped_path = self.root.clone(); @@ -122,6 +127,9 @@ impl MemoriesBackend for LocalMemoriesBackend { } else if metadata.is_dir() { let mut entries = Vec::new(); for path in read_sorted_dir_paths(&start).await? { + if is_hidden_path(&path) { + continue; + } let Some(metadata) = Self::metadata_or_none(&path).await? else { continue; }; @@ -288,6 +296,9 @@ async fn search_entries( let mut pending = vec![current.to_path_buf()]; while let Some(dir_path) = pending.pop() { for path in read_sorted_dir_paths(&dir_path).await? { + if is_hidden_path(&path) { + continue; + } let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&path).await? else { continue; }; @@ -403,6 +414,18 @@ fn reject_symlink(path: &str, metadata: &std::fs::Metadata) -> Result<(), Memori Ok(()) } +fn is_hidden_component(component: Component<'_>) -> bool { + matches!( + component, + Component::Normal(name) if name.to_string_lossy().starts_with('.') + ) +} + +fn is_hidden_path(path: &Path) -> bool { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with('.')) +} + fn display_relative_path(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) diff --git a/codex-rs/memories/mcp/src/local_tests.rs b/codex-rs/memories/mcp/src/local_tests.rs index 51a10ca3b..777348953 100644 --- a/codex-rs/memories/mcp/src/local_tests.rs +++ b/codex-rs/memories/mcp/src/local_tests.rs @@ -26,9 +26,15 @@ async fn list_returns_shallow_memory_paths() { tokio::fs::create_dir_all(tempdir.path().join("skills/example")) .await .expect("create skills dir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary") .await .expect("write memory file"); + tokio::fs::write(tempdir.path().join(".DS_Store"), "metadata") + .await + .expect("write hidden file"); tokio::fs::write(tempdir.path().join("skills/example/SKILL.md"), "skill") .await .expect("write skill file"); @@ -193,6 +199,25 @@ async fn list_scoped_directory_is_shallow() { ); } +#[tokio::test] +async fn list_rejects_hidden_scoped_paths() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); + + let err = backend(&tempdir) + .list(ListMemoriesRequest { + path: Some(".git".to_string()), + cursor: None, + max_results: DEFAULT_LIST_MAX_RESULTS, + }) + .await + .expect_err("hidden scoped paths should stay invisible"); + + assert!(matches!(err, MemoriesBackendError::NotFound { .. })); +} + #[tokio::test] async fn list_rejects_invalid_cursor() { let tempdir = TempDir::new().expect("tempdir"); @@ -315,6 +340,29 @@ async fn read_supports_line_offset() { ); } +#[tokio::test] +async fn read_rejects_hidden_paths() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); + tokio::fs::write(tempdir.path().join(".git/HEAD"), "ref: refs/heads/main\n") + .await + .expect("write hidden file"); + + let err = backend(&tempdir) + .read(ReadMemoryRequest { + path: ".git/HEAD".to_string(), + line_offset: 1, + max_lines: None, + max_tokens: DEFAULT_READ_MAX_TOKENS, + }) + .await + .expect_err("hidden paths should stay invisible"); + + assert!(matches!(err, MemoriesBackendError::NotFound { .. })); +} + #[tokio::test] async fn read_supports_max_lines() { let tempdir = TempDir::new().expect("tempdir"); @@ -559,6 +607,56 @@ async fn search_preserves_global_lexicographic_path_order() { ); } +#[tokio::test] +async fn search_skips_hidden_paths() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); + tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle visible\n") + .await + .expect("write visible file"); + tokio::fs::write(tempdir.path().join(".git/HEAD"), "needle hidden\n") + .await + .expect("write hidden file"); + tokio::fs::write(tempdir.path().join(".hidden"), "needle hidden\n") + .await + .expect("write hidden file"); + + let response = backend(&tempdir) + .search(search_request(&["needle"])) + .await + .expect("search memories"); + + assert_eq!( + response.matches, + vec![MemorySearchMatch { + path: "MEMORY.md".to_string(), + match_line_number: 1, + content_start_line_number: 1, + content: "needle visible".to_string(), + matched_queries: vec!["needle".to_string()], + }] + ); +} + +#[tokio::test] +async fn search_rejects_hidden_scoped_paths() { + let tempdir = TempDir::new().expect("tempdir"); + tokio::fs::create_dir_all(tempdir.path().join(".git")) + .await + .expect("create hidden dir"); + + let mut request = search_request(&["needle"]); + request.path = Some(".git".to_string()); + let err = backend(&tempdir) + .search(request) + .await + .expect_err("hidden scoped paths should stay invisible"); + + assert!(matches!(err, MemoriesBackendError::NotFound { .. })); +} + #[tokio::test] async fn search_supports_context_lines() { let tempdir = TempDir::new().expect("tempdir");