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.
This commit is contained in:
jif-oai
2026-05-05 16:59:05 +02:00
committed by GitHub
parent 70807730f5
commit de924af134
2 changed files with 121 additions and 0 deletions

View File

@@ -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::<Vec<_>>();
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)

View File

@@ -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");