From c7174e9852ae155236d4046d2651adf24fc82728 Mon Sep 17 00:00:00 2001 From: Saurabh Meena <127095776+SaurabhScripts@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:12:30 +0530 Subject: [PATCH 1/4] fix(context): reject Markdown import symlinks --- docs/reference/context.md | 3 +- semantica/context/agent_memory.py | 60 ++++++++++++--- tests/context/test_agent_memory_markdown.py | 81 +++++++++++++++++++++ 3 files changed, 132 insertions(+), 12 deletions(-) diff --git a/docs/reference/context.md b/docs/reference/context.md index 18950e9e..94bcd115 100644 --- a/docs/reference/context.md +++ b/docs/reference/context.md @@ -626,7 +626,8 @@ files is idempotent. Memory-local `entities` and `relationships` are preserved a provenance but are not applied to `ContextGraph` by Markdown import. Use a dedicated export directory: matching files are overwritten, but unrelated or stale Markdown files are not deleted automatically. Export refuses to overwrite symbolic links and -uses atomic file replacement. Timestamp offsets are preserved in Markdown and +uses atomic file replacement; import also refuses symbolic-link files and directories. +Timestamp offsets are preserved in Markdown and normalized to UTC only for comparisons, so aware and local-naive records can be queried together safely. Vector-store writes are deferred until the in-memory import commits; adapter synchronization remains best-effort and logs failures. diff --git a/semantica/context/agent_memory.py b/semantica/context/agent_memory.py index 2f2b8995..93c8b568 100644 --- a/semantica/context/agent_memory.py +++ b/semantica/context/agent_memory.py @@ -59,9 +59,11 @@ License: MIT """ import copy +import errno import hashlib import os import re +import stat import tempfile from collections import deque from dataclasses import dataclass, field @@ -1865,7 +1867,8 @@ class AgentMemory: if "\n" not in data and "\r" not in data: candidate = Path(data) try: - candidate_exists = candidate.exists() + candidate_is_symlink = candidate.is_symlink() + candidate_exists = candidate_is_symlink or candidate.exists() except OSError as exc: error_message = ( "Failed to inspect possible Markdown import " @@ -1907,29 +1910,64 @@ class AgentMemory: return memories def _read_markdown_path(self, path: Path) -> List[Tuple[str, str]]: + if path.is_symlink(): + raise ValueError(f"Refusing to import Markdown symbolic link: {path}") + if not path.exists(): raise FileNotFoundError(f"Markdown import path does not exist: {path}") if path.is_dir(): - file_paths = sorted( - ( - file_path - for file_path in path.iterdir() - if file_path.is_file() - and file_path.suffix.lower() in self._MARKDOWN_EXTENSIONS - ), - key=lambda file_path: (file_path.name.casefold(), file_path.name), - ) + file_paths = [] + for file_path in path.iterdir(): + if file_path.suffix.lower() not in self._MARKDOWN_EXTENSIONS: + continue + if file_path.is_symlink(): + raise ValueError( + f"Refusing to import Markdown symbolic link: {file_path}" + ) + if file_path.is_file(): + file_paths.append(file_path) + file_paths.sort(key=lambda item: (item.name.casefold(), item.name)) elif path.is_file(): file_paths = [path] else: raise ValueError(f"Markdown import path is not a file or directory: {path}") return [ - (str(file_path), file_path.read_text(encoding="utf-8")) + (str(file_path), self._read_markdown_file(file_path)) for file_path in file_paths ] + @staticmethod + def _read_markdown_file(file_path: Path) -> str: + """Read a regular Markdown file without following a raced symlink.""" + if file_path.is_symlink(): + raise ValueError(f"Refusing to import Markdown symbolic link: {file_path}") + + flags = os.O_RDONLY + nofollow_flag = getattr(os, "O_NOFOLLOW", 0) + flags |= nofollow_flag + try: + file_descriptor = os.open(file_path, flags) + except OSError as exc: + if nofollow_flag and exc.errno == errno.ELOOP: + raise ValueError( + f"Refusing to import Markdown symbolic link: {file_path}" + ) from exc + raise + + try: + if not stat.S_ISREG(os.fstat(file_descriptor).st_mode): + raise ValueError( + f"Markdown import path is not a regular file: {file_path}" + ) + with os.fdopen(file_descriptor, mode="r", encoding="utf-8") as source: + file_descriptor = -1 + return source.read() + finally: + if file_descriptor >= 0: + os.close(file_descriptor) + def _markdown_to_memory_dict( self, document: str, source: str = "markdown document" ) -> Dict[str, Any]: diff --git a/tests/context/test_agent_memory_markdown.py b/tests/context/test_agent_memory_markdown.py index fb9158ed..d403504c 100644 --- a/tests/context/test_agent_memory_markdown.py +++ b/tests/context/test_agent_memory_markdown.py @@ -1,6 +1,8 @@ import errno +import os from copy import deepcopy from datetime import datetime, timedelta, timezone +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -698,6 +700,85 @@ def test_markdown_string_path_inspection_errors_are_actionable(): assert exc_info.value.__cause__ is original_error +@pytest.mark.parametrize("use_string_path", [False, True]) +def test_markdown_import_rejects_symlinked_file(tmp_path, use_string_path): + outside = tmp_path / "outside.md" + outside.write_text( + markdown_document(required_frontmatter(), "Do not import"), + encoding="utf-8", + ) + source = tmp_path / "memory.md" + source.symlink_to(outside) + payload = str(source) if use_string_path else source + memory = AgentMemory() + + with pytest.raises(ValueError, match="symbolic link"): + memory.import_data(payload, format="markdown") + + assert memory.count() == 0 + + +@pytest.mark.parametrize("use_string_path", [False, True]) +def test_markdown_import_rejects_broken_symlink(tmp_path, use_string_path): + source = tmp_path / "missing-memory.md" + source.symlink_to(tmp_path / "missing-target.md") + payload = str(source) if use_string_path else source + + with pytest.raises(ValueError, match="symbolic link"): + AgentMemory().import_data(payload, format="markdown") + + +@pytest.mark.parametrize("use_string_path", [False, True]) +def test_markdown_import_rejects_symlinked_directory(tmp_path, use_string_path): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "memory.md").write_text( + markdown_document(required_frontmatter(), "Do not import"), + encoding="utf-8", + ) + source = tmp_path / "memory-export" + source.symlink_to(outside, target_is_directory=True) + payload = str(source) if use_string_path else source + memory = AgentMemory() + + with pytest.raises(ValueError, match="symbolic link"): + memory.import_data(payload, format="markdown") + + assert memory.count() == 0 + + +def test_markdown_import_rejects_symlinked_file_in_directory(tmp_path): + outside = tmp_path / "outside.md" + outside.write_text( + markdown_document(required_frontmatter(), "Do not import"), + encoding="utf-8", + ) + source = tmp_path / "memory-export" + source.mkdir() + (source / "memory.md").symlink_to(outside) + memory = AgentMemory() + + with pytest.raises(ValueError, match="symbolic link"): + memory.import_data(source, format="markdown") + + assert memory.count() == 0 + + +@pytest.mark.skipif(not hasattr(os, "O_NOFOLLOW"), reason="requires O_NOFOLLOW") +def test_markdown_import_does_not_follow_symlink_raced_before_open(tmp_path): + outside = tmp_path / "outside.md" + outside.write_text( + markdown_document(required_frontmatter(), "Do not import"), + encoding="utf-8", + ) + source = tmp_path / "memory.md" + source.symlink_to(outside) + + with patch.object(Path, "is_symlink", return_value=False): + with pytest.raises(ValueError, match="symbolic link"): + AgentMemory._read_markdown_file(source) + + def test_legacy_dict_import_behavior_is_unchanged(): memory = AgentMemory() data = { From 1b9bb4c345b017b97b57ef30ce8fe8361ea65dac Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Sat, 8 Aug 2026 15:44:26 +0530 Subject: [PATCH 2/4] test(context): harden Markdown import symlink coverage --- semantica/context/agent_memory.py | 8 ++++++ tests/context/test_agent_memory_markdown.py | 29 ++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/semantica/context/agent_memory.py b/semantica/context/agent_memory.py index 93c8b568..7fef3b38 100644 --- a/semantica/context/agent_memory.py +++ b/semantica/context/agent_memory.py @@ -1944,6 +1944,14 @@ class AgentMemory: if file_path.is_symlink(): raise ValueError(f"Refusing to import Markdown symbolic link: {file_path}") + # Defend the open itself against a symlink introduced after the + # is_symlink() check above (TOCTOU). O_NOFOLLOW is used where the + # platform supports it, causing os.open() to fail with ELOOP if the + # target became a symlink in the meantime. On platforms without + # O_NOFOLLOW (e.g. Windows), os.open() follows symlinks and there is + # no kernel-level way to close this race; the preceding + # is_symlink() check is the only protection there, so the strength + # of the final-open race protection differs by platform. flags = os.O_RDONLY nofollow_flag = getattr(os, "O_NOFOLLOW", 0) flags |= nofollow_flag diff --git a/tests/context/test_agent_memory_markdown.py b/tests/context/test_agent_memory_markdown.py index d403504c..807e5858 100644 --- a/tests/context/test_agent_memory_markdown.py +++ b/tests/context/test_agent_memory_markdown.py @@ -55,6 +55,25 @@ def markdown_document(frontmatter, body=""): return f"---\n{yaml_text}---\n\n{body}" +def _require_symlink_support(tmp_path): + """Skip the test if this environment cannot create symbolic links. + + Symlink creation can be unavailable even on POSIX (e.g. restricted + containers) and commonly requires elevated privilege or Developer Mode + on Windows. Probe actual capability instead of assuming based on + platform, so these tests still run wherever symlinks genuinely work. + """ + probe_target = tmp_path / ".symlink_probe_target" + probe_link = tmp_path / ".symlink_probe_link" + probe_target.write_text("", encoding="utf-8") + try: + probe_link.symlink_to(probe_target) + except OSError as exc: + pytest.skip(f"environment cannot create symbolic links: {exc}") + probe_link.unlink() + probe_target.unlink() + + def required_frontmatter(memory_id="mem_test", **overrides): frontmatter = { "id": memory_id, @@ -702,6 +721,7 @@ def test_markdown_string_path_inspection_errors_are_actionable(): @pytest.mark.parametrize("use_string_path", [False, True]) def test_markdown_import_rejects_symlinked_file(tmp_path, use_string_path): + _require_symlink_support(tmp_path) outside = tmp_path / "outside.md" outside.write_text( markdown_document(required_frontmatter(), "Do not import"), @@ -720,16 +740,21 @@ def test_markdown_import_rejects_symlinked_file(tmp_path, use_string_path): @pytest.mark.parametrize("use_string_path", [False, True]) def test_markdown_import_rejects_broken_symlink(tmp_path, use_string_path): + _require_symlink_support(tmp_path) source = tmp_path / "missing-memory.md" source.symlink_to(tmp_path / "missing-target.md") payload = str(source) if use_string_path else source + memory = AgentMemory() with pytest.raises(ValueError, match="symbolic link"): - AgentMemory().import_data(payload, format="markdown") + memory.import_data(payload, format="markdown") + + assert memory.count() == 0 @pytest.mark.parametrize("use_string_path", [False, True]) def test_markdown_import_rejects_symlinked_directory(tmp_path, use_string_path): + _require_symlink_support(tmp_path) outside = tmp_path / "outside" outside.mkdir() (outside / "memory.md").write_text( @@ -748,6 +773,7 @@ def test_markdown_import_rejects_symlinked_directory(tmp_path, use_string_path): def test_markdown_import_rejects_symlinked_file_in_directory(tmp_path): + _require_symlink_support(tmp_path) outside = tmp_path / "outside.md" outside.write_text( markdown_document(required_frontmatter(), "Do not import"), @@ -766,6 +792,7 @@ def test_markdown_import_rejects_symlinked_file_in_directory(tmp_path): @pytest.mark.skipif(not hasattr(os, "O_NOFOLLOW"), reason="requires O_NOFOLLOW") def test_markdown_import_does_not_follow_symlink_raced_before_open(tmp_path): + _require_symlink_support(tmp_path) outside = tmp_path / "outside.md" outside.write_text( markdown_document(required_frontmatter(), "Do not import"), From a3d8064f3d397f6be2c45c60f2711a128bc0dbeb Mon Sep 17 00:00:00 2001 From: Saurabh Meena <127095776+SaurabhScripts@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:37:31 +0530 Subject: [PATCH 3/4] docs: add Markdown import hardening changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 244b45fc..9a8a8170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Markdown import followed symbolic links even though Markdown export already refused to overwrite them** (#851, follow-up to #765, #786) by @SaurabhScripts + - `AgentMemory._read_markdown_path()` now rejects a symlink file, a broken symlink, or a symlinked directory supplied directly as an import path, and rejects any symlinked Markdown entry discovered while walking an import directory - before any parsing happens, so an import can no longer read a different file or directory than the path presented to the caller + - New `_read_markdown_file()` re-checks `is_symlink()` immediately before opening (closing the window between directory-scan validation and the actual read), opens with `O_NOFOLLOW` on platforms that support it, and verifies the resulting descriptor is a regular file via `fstat`/`S_ISREG` before reading, so a symlink swapped in after validation is still rejected rather than silently followed + - Documented the import restriction in `docs/reference/context.md`; added 8 tests to `tests/context/test_agent_memory_markdown.py` covering file/directory/broken-symlink rejection for both `str` and `Path` inputs, plus a simulated-race test proving the `O_NOFOLLOW` open still catches a symlink when the pre-open `is_symlink()` check is bypassed + - Disclosed limitation: `O_NOFOLLOW` isn't available on Windows, so the final open there relies solely on the pre-open `is_symlink()` check rather than a kernel-enforced guarantee against a race + - Any additional review follow-up commits land in this same PR/entry rather than as a separate changelog item + - **`DecisionEmbeddingPipeline.find_similar_decisions()` crashed with `AttributeError` for any `VectorStore` backend other than `inmemory`** (#842, closes #839) by @Sameer6305 - `_get_candidate_embeddings()` iterated `VectorStore.vectors`/`VectorStore.metadata` directly, internal dicts only populated for `backend="inmemory"`; every persistent backend (FAISS, Pinecone, Qdrant, Milvus, ...) raised `AttributeError`. It now fetches candidates via the backend-agnostic `VectorStore.search_vectors()`, reading metadata via a `res.get("metadata") or res.get("payload")` fallback for backends that key it differently - Backends such as FAISS don't return the raw vector for each hit; `find_similar_decisions()` and `_find_semantic_similar()` now fall back to the search-provided score (normalized from `distance` when present) as the semantic similarity for those candidates instead of computing cosine similarity against a zero placeholder vector From 560ffef59fa7f6bc754779c427beab56f5d72f14 Mon Sep 17 00:00:00 2001 From: Saurabh Meena <127095776+SaurabhScripts@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:35:03 +0530 Subject: [PATCH 4/4] fix(context): reject Markdown junction imports --- CHANGELOG.md | 10 ++-- docs/reference/context.md | 5 +- semantica/context/_markdown_filesystem.py | 32 +++++++++++ semantica/context/agent_memory.py | 48 ++++++++++------ tests/context/test_agent_memory_markdown.py | 62 +++++++++++++++++++++ 5 files changed, 134 insertions(+), 23 deletions(-) create mode 100644 semantica/context/_markdown_filesystem.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 372550a2..639e6569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,11 +69,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Markdown import followed symbolic links even though Markdown export already refused to overwrite them** (#851, follow-up to #765, #786) by @SaurabhScripts - - `AgentMemory._read_markdown_path()` now rejects a symlink file, a broken symlink, or a symlinked directory supplied directly as an import path, and rejects any symlinked Markdown entry discovered while walking an import directory - before any parsing happens, so an import can no longer read a different file or directory than the path presented to the caller - - New `_read_markdown_file()` re-checks `is_symlink()` immediately before opening (closing the window between directory-scan validation and the actual read), opens with `O_NOFOLLOW` on platforms that support it, and verifies the resulting descriptor is a regular file via `fstat`/`S_ISREG` before reading, so a symlink swapped in after validation is still rejected rather than silently followed - - Documented the import restriction in `docs/reference/context.md`; added 8 tests to `tests/context/test_agent_memory_markdown.py` covering file/directory/broken-symlink rejection for both `str` and `Path` inputs, plus a simulated-race test proving the `O_NOFOLLOW` open still catches a symlink when the pre-open `is_symlink()` check is bypassed - - Disclosed limitation: `O_NOFOLLOW` isn't available on Windows, so the final open there relies solely on the pre-open `is_symlink()` check rather than a kernel-enforced guarantee against a race +- **Markdown import followed filesystem links even though Markdown export already refused to overwrite them** (#851, follow-up to #765, #786) by @SaurabhScripts + - `AgentMemory._read_markdown_path()` now rejects symlink files, broken symlinks, symlinked directories, Windows directory junctions, and other Windows reparse points before parsing, including linked Markdown entries discovered while walking an import directory + - New `_read_markdown_file()` re-checks the file and parent directory immediately before and after opening, uses `O_NOFOLLOW` where available, and verifies the resulting descriptor is a regular file via `fstat`/`S_ISREG`, so link swaps are rejected rather than silently followed + - Junction detection uses `os.path.isjunction()` where available and falls back to the Windows reparse-point file attribute on older Python versions; export applies the same link check before replacing a Markdown file + - Documented the import restriction in `docs/reference/context.md`; added 11 tests to `tests/context/test_agent_memory_markdown.py` covering file/directory/broken-symlink rejection, simulated open races, mocked and real Windows junctions, and the reparse-point fallback - Any additional review follow-up commits land in this same PR/entry rather than as a separate changelog item - **`VectorStore.search_vectors()` returned inconsistent result shapes across backend implementations** (#853, closes #845) by @Sameer6305, reviewed by @KaifAhmad1 diff --git a/docs/reference/context.md b/docs/reference/context.md index 94bcd115..02ac9afc 100644 --- a/docs/reference/context.md +++ b/docs/reference/context.md @@ -625,8 +625,9 @@ malformed or duplicate fields before changing memory, and re-importing unchanged files is idempotent. Memory-local `entities` and `relationships` are preserved as provenance but are not applied to `ContextGraph` by Markdown import. Use a dedicated export directory: matching files are overwritten, but unrelated or stale Markdown -files are not deleted automatically. Export refuses to overwrite symbolic links and -uses atomic file replacement; import also refuses symbolic-link files and directories. +files are not deleted automatically. Export refuses to overwrite filesystem links and +uses atomic file replacement; import also refuses symlinks, Windows directory +junctions, and other Windows reparse points. Timestamp offsets are preserved in Markdown and normalized to UTC only for comparisons, so aware and local-naive records can be queried together safely. Vector-store writes are deferred until the in-memory import diff --git a/semantica/context/_markdown_filesystem.py b/semantica/context/_markdown_filesystem.py new file mode 100644 index 00000000..70872f85 --- /dev/null +++ b/semantica/context/_markdown_filesystem.py @@ -0,0 +1,32 @@ +"""Filesystem safety helpers for human-editable Markdown persistence.""" + +import os +import stat +from pathlib import Path +from typing import Optional + + +def is_filesystem_link(path: Path) -> bool: + """Return whether *path* is a symlink, junction, or Windows reparse point.""" + if path.is_symlink(): + return True + + isjunction = getattr(os.path, "isjunction", None) + if isjunction is not None and isjunction(path): + return True + + try: + attributes = getattr(os.lstat(path), "st_file_attributes", 0) + except (FileNotFoundError, NotADirectoryError): + return False + + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return bool(attributes & reparse_point) + + +def find_filesystem_link(path: Path) -> Optional[Path]: + """Return the first linked component in *path*, including its ancestors.""" + for candidate in (path, *path.parents): + if is_filesystem_link(candidate): + return candidate + return None diff --git a/semantica/context/agent_memory.py b/semantica/context/agent_memory.py index 7fef3b38..cd98dab2 100644 --- a/semantica/context/agent_memory.py +++ b/semantica/context/agent_memory.py @@ -77,6 +77,7 @@ import yaml from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker from ..utils.types import EntityDict, RelationshipDict +from ._markdown_filesystem import find_filesystem_link class _UniqueKeySafeLoader(yaml.SafeLoader): @@ -1742,9 +1743,10 @@ class AgentMemory: @staticmethod def _write_markdown_file(file_path: Path, document: str) -> None: """Atomically replace a Markdown file without following output symlinks.""" - if file_path.is_symlink(): + if find_filesystem_link(file_path) is not None: raise ValueError( - f"Refusing to overwrite Markdown symbolic link: {file_path}" + "Refusing to overwrite Markdown symbolic link or junction: " + f"{file_path}" ) temporary_path = None @@ -1867,8 +1869,8 @@ class AgentMemory: if "\n" not in data and "\r" not in data: candidate = Path(data) try: - candidate_is_symlink = candidate.is_symlink() - candidate_exists = candidate_is_symlink or candidate.exists() + candidate_is_link = find_filesystem_link(candidate) is not None + candidate_exists = candidate_is_link or candidate.exists() except OSError as exc: error_message = ( "Failed to inspect possible Markdown import " @@ -1910,8 +1912,10 @@ class AgentMemory: return memories def _read_markdown_path(self, path: Path) -> List[Tuple[str, str]]: - if path.is_symlink(): - raise ValueError(f"Refusing to import Markdown symbolic link: {path}") + if find_filesystem_link(path) is not None: + raise ValueError( + f"Refusing to import Markdown symbolic link or junction: {path}" + ) if not path.exists(): raise FileNotFoundError(f"Markdown import path does not exist: {path}") @@ -1921,9 +1925,10 @@ class AgentMemory: for file_path in path.iterdir(): if file_path.suffix.lower() not in self._MARKDOWN_EXTENSIONS: continue - if file_path.is_symlink(): + if find_filesystem_link(file_path) is not None: raise ValueError( - f"Refusing to import Markdown symbolic link: {file_path}" + "Refusing to import Markdown symbolic link or junction: " + f"{file_path}" ) if file_path.is_file(): file_paths.append(file_path) @@ -1941,30 +1946,41 @@ class AgentMemory: @staticmethod def _read_markdown_file(file_path: Path) -> str: """Read a regular Markdown file without following a raced symlink.""" - if file_path.is_symlink(): - raise ValueError(f"Refusing to import Markdown symbolic link: {file_path}") + if find_filesystem_link(file_path) is not None: + raise ValueError( + f"Refusing to import Markdown symbolic link or junction: {file_path}" + ) # Defend the open itself against a symlink introduced after the # is_symlink() check above (TOCTOU). O_NOFOLLOW is used where the # platform supports it, causing os.open() to fail with ELOOP if the # target became a symlink in the meantime. On platforms without - # O_NOFOLLOW (e.g. Windows), os.open() follows symlinks and there is - # no kernel-level way to close this race; the preceding - # is_symlink() check is the only protection there, so the strength - # of the final-open race protection differs by platform. + # O_NOFOLLOW (e.g. Windows), os.open() may follow a link introduced + # during the open. The pre/post-open reparse-point checks still reject + # persistent swaps, but they cannot provide the same kernel-enforced + # guarantee as O_NOFOLLOW. flags = os.O_RDONLY nofollow_flag = getattr(os, "O_NOFOLLOW", 0) flags |= nofollow_flag try: file_descriptor = os.open(file_path, flags) except OSError as exc: - if nofollow_flag and exc.errno == errno.ELOOP: + if ( + (nofollow_flag and exc.errno == errno.ELOOP) + or find_filesystem_link(file_path) is not None + ): raise ValueError( - f"Refusing to import Markdown symbolic link: {file_path}" + "Refusing to import Markdown symbolic link or junction: " + f"{file_path}" ) from exc raise try: + if find_filesystem_link(file_path) is not None: + raise ValueError( + "Refusing to import Markdown symbolic link or junction: " + f"{file_path}" + ) if not stat.S_ISREG(os.fstat(file_descriptor).st_mode): raise ValueError( f"Markdown import path is not a regular file: {file_path}" diff --git a/tests/context/test_agent_memory_markdown.py b/tests/context/test_agent_memory_markdown.py index 807e5858..44f75bc9 100644 --- a/tests/context/test_agent_memory_markdown.py +++ b/tests/context/test_agent_memory_markdown.py @@ -1,5 +1,7 @@ import errno import os +import stat +import subprocess from copy import deepcopy from datetime import datetime, timedelta, timezone from pathlib import Path @@ -806,6 +808,66 @@ def test_markdown_import_does_not_follow_symlink_raced_before_open(tmp_path): AgentMemory._read_markdown_file(source) +def test_markdown_import_rejects_windows_junction(tmp_path, monkeypatch): + source = tmp_path / "junction" + source.mkdir() + (source / "memory.md").write_text("not read", encoding="utf-8") + + monkeypatch.setattr( + os.path, + "isjunction", + lambda candidate: Path(candidate) == source, + raising=False, + ) + + with pytest.raises(ValueError, match="junction"): + AgentMemory().import_data(source, format="markdown") + + +def test_markdown_import_rejects_windows_reparse_point_fallback(tmp_path, monkeypatch): + source = tmp_path / "reparse-point" + source.mkdir() + real_lstat = os.lstat + + class ReparseStat: + st_file_attributes = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + + monkeypatch.delattr(os.path, "isjunction", raising=False) + monkeypatch.setattr(Path, "is_symlink", lambda self: False) + monkeypatch.setattr( + os, + "lstat", + lambda candidate: ( + ReparseStat() if Path(candidate) == source else real_lstat(candidate) + ), + ) + + with pytest.raises(ValueError, match="junction"): + AgentMemory().import_data(source, format="markdown") + + +@pytest.mark.skipif(os.name != "nt", reason="requires Windows junctions") +def test_markdown_import_rejects_real_windows_junction(tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "memory.md").write_text("not read", encoding="utf-8") + source = tmp_path / "junction" + result = subprocess.run( + ["cmd.exe", "/c", "mklink", "/J", str(source), str(outside)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip(f"could not create Windows junction: {result.stderr}") + + try: + with pytest.raises(ValueError, match="junction"): + AgentMemory().import_data(source, format="markdown") + finally: + os.rmdir(source) + + def test_legacy_dict_import_behavior_is_unchanged(): memory = AgentMemory() data = {