From 1a571e978b9961b228fb828ec10fd312c8c41166 Mon Sep 17 00:00:00 2001 From: PureTensor Date: Thu, 22 Oct 2026 08:32:56 +0100 Subject: [PATCH] v0.1.6: puremind_get and the indexer open notes through an anchored no-symlink directory chain An ancestor directory swapped for a symlink after is_indexable() could redirect the open outside the vault; O_NOFOLLOW covers only the last component. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++++ src/puremind_mac/__init__.py | 2 +- src/puremind_mac/indexer.py | 26 +++++++++++++++++++++++++- src/puremind_mac/tools.py | 3 ++- tests/test_tools.py | 19 +++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f636496..b338828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.6 (2026-10-22) + +- `puremind_get` and the indexer now open a note through a chain of directory descriptors that refuse a symlink at every component. Before, only the last component was protected, so a folder swapped for a symlink after the path check could make `puremind_get` serve a file from outside the vault. + ## 0.1.5 (2026-10-18) Faster with large vaults. Measured on Linux with a synthetic vault of 50,000 chunks (20,000 in brackets), against 0.1.4: diff --git a/src/puremind_mac/__init__.py b/src/puremind_mac/__init__.py index 98b86f1..8735018 100644 --- a/src/puremind_mac/__init__.py +++ b/src/puremind_mac/__init__.py @@ -1,3 +1,3 @@ """pureMind for Mac: local memory for AI coding agents, served over MCP.""" -__version__ = "0.1.5" +__version__ = "0.1.6" diff --git a/src/puremind_mac/indexer.py b/src/puremind_mac/indexer.py index a94692f..873911f 100644 --- a/src/puremind_mac/indexer.py +++ b/src/puremind_mac/indexer.py @@ -101,6 +101,30 @@ def is_indexable(root: Path, path: Path) -> Path | None: return real +def open_resolved(path: Path, flags: int) -> int: + """Open the already-resolved ``path``, refusing a symlink at ANY component. + + O_NOFOLLOW alone covers only the last component, so an ancestor directory swapped for a + symlink after is_indexable() would redirect the open outside the vault. Walk from the + file system root one directory descriptor at a time instead. + """ + nofollow = getattr(os, "O_NOFOLLOW", 0) + dir_flags = os.O_RDONLY | nofollow | getattr(os, "O_DIRECTORY", 0) + parts = os.fsencode(path).split(b"/") + names = [p for p in parts if p] + if not os.path.isabs(path) or not names: + raise OSError(errno.EINVAL, "not an absolute file path") + dir_fd = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + for name in names[:-1]: + nxt = os.open(name, dir_flags, dir_fd=dir_fd) + os.close(dir_fd) + dir_fd = nxt + return os.open(names[-1], flags | nofollow, dir_fd=dir_fd) + finally: + os.close(dir_fd) + + def _printable(name: str | os.PathLike[str]) -> str: """A file name safe to print: bytes that are not valid UTF-8 appear as \\xNN escapes.""" return os.fsencode(name).decode("utf-8", "backslashreplace") @@ -198,7 +222,7 @@ def _read_note(path: Path) -> tuple[bytes, os.stat_result]: """Bytes and stat of a regular file, read without blocking on a pipe, following a symlink or passing the size cap. ``path`` is already resolved (see is_indexable).""" flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) # a FIFO swapped in must not hang us - fd = os.open(path, flags) + fd = open_resolved(path, flags) try: st = os.fstat(fd) if not stat.S_ISREG(st.st_mode): diff --git a/src/puremind_mac/tools.py b/src/puremind_mac/tools.py index 3ce7071..84c7ed7 100644 --- a/src/puremind_mac/tools.py +++ b/src/puremind_mac/tools.py @@ -20,6 +20,7 @@ _excluded_part, _same_directory, is_indexable, + open_resolved, ) from .models import ModelLoadError from .service import IndexMismatch, Service @@ -157,7 +158,7 @@ def _spellings(path: str) -> list[str]: def _read_note(path: Path) -> tuple[str, bool]: """Up to 4 * MAX_GET_CHARS bytes of the note as text, and whether the file continued beyond them.""" flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) - with os.fdopen(os.open(path, flags), "rb") as fh: + with os.fdopen(open_resolved(path, flags), "rb") as fh: st = os.fstat(fh.fileno()) if not stat.S_ISREG(st.st_mode): raise ToolRefused("not a regular Markdown note") diff --git a/tests/test_tools.py b/tests/test_tools.py index 764d022..6ab0869 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -530,3 +530,22 @@ def test_tools_never_modify_the_vault(service, vault): tools.puremind_get(service, "projects/garden.md") tools.puremind_recent(service) assert before == {p: p.read_bytes() for p in vault.rglob("*.md")} + + +def test_get_rejects_ancestor_directory_swapped_after_validation(service, vault, tmp_path, monkeypatch): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "garden.md").write_text("OUTSIDE_VAULT_CANARY") + (vault / "projects").mkdir(exist_ok=True) + (vault / "projects" / "garden.md").write_text("inside") + original = tools._read_note + + def swap_then_read(path): + (vault / "projects").rename(vault / "original-projects") + (vault / "projects").symlink_to(outside, target_is_directory=True) + return original(path) + + monkeypatch.setattr(tools, "_read_note", swap_then_read) + with pytest.raises(tools.ToolRefused): + result = tools.puremind_get(service, "projects/garden.md") + assert "OUTSIDE_VAULT_CANARY" not in result