Description
On Windows, FileSystemAgentFileStore applies different root-containment behavior to direct file access and recursive file search.
A direct read through an NTFS directory junction is rejected because the resolved target is outside the configured store root. However, recursive search() enumerates descendants and skips only entries for which Path.is_symlink() is true. Windows directory junctions return False from is_symlink(), so recursive search descends the junction and reads matching text files outside the configured root.
FileAccessProvider.file_access_grep uses this recursive search path and can therefore return outside-root file names, matching lines, and snippets to the agent.
This was previously reported privately to MSRC. MSRC assessed it as Low severity, confirmed that the behavior is being addressed so junctions are treated consistently, shared the report with the responsible team, and closed the case.
Expected Behavior
Direct reads, directory listing, and recursive search should consistently reject or skip Windows junctions and other reparse points. No file outside the configured FileSystemAgentFileStore root should be returned by recursive search.
Actual Behavior
The direct read is blocked, but recursive grep returns the outside file:
link_is_symlink=False
link_is_junction=True
direct_read_blocked=True
grep_leaked_outside=True
read_text=Could not read file 'linked_outside/secret.md': Invalid path: 'linked_outside/secret.md'. The resolved path escapes the root directory.
grep_text=[{"file_name": "linked_outside/secret.md", "snippet": "PROVIDER_OUTSIDE_CANARY\n", "matching_lines": [{"line_number": 1, "line": "PROVIDER_OUTSIDE_CANARY"}]}]
confirmed=True
Root Cause
- Direct access calls
_resolve_safe_path(), resolves the candidate, and verifies that it remains under the configured root.
_list_sync() and _enumerate_search_files() skip only entry.is_symlink().
- On Windows, an NTFS directory junction is a reparse point but is not reported as a symbolic link by
Path.is_symlink().
- Recursive enumeration consequently descends the junction.
_search_files_sync() then calls entry.read_text() on the outside-root files.
Current affected source:
Code Sample
The following local-only reproducer creates temporary directories and a fake canary file. It does not access cloud services, credentials, or real user files.
import asyncio
import subprocess
import tempfile
from pathlib import Path
from agent_framework import FileSystemAgentFileStore
async def main() -> None:
with tempfile.TemporaryDirectory(prefix="af-junction-repro-") as temp_name:
base = Path(temp_name)
store_root = base / "store"
outside = base / "outside"
junction = store_root / "linked_outside"
store_root.mkdir()
outside.mkdir()
(outside / "secret.md").write_text("PROVIDER_OUTSIDE_CANARY\n", encoding="utf-8")
result = subprocess.run(
["cmd", "/c", "mklink", "/J", str(junction), str(outside)],
capture_output=True,
text=True,
check=False,
)
if result.returncode:
raise RuntimeError(result.stderr or result.stdout)
try:
store = FileSystemAgentFileStore(store_root)
print(f"link_is_symlink={junction.is_symlink()}")
print(f"link_is_junction={junction.is_junction()}")
try:
await store.read("linked_outside/secret.md")
except ValueError:
print("direct_read_blocked=True")
matches = await store.search("", "PROVIDER_OUTSIDE_CANARY", recursive=True)
print(f"recursive_search_matches={matches}")
finally:
subprocess.run(
["cmd", "/c", "rmdir", str(junction)],
capture_output=True,
text=True,
check=False,
)
asyncio.run(main())
Run on Windows:
python poc_junction_search.py
Error Messages / Stack Traces
No unhandled exception is required. The security-relevant inconsistency is that the direct read raises a root-escape error while recursive search successfully returns the same outside-root file.
Package Versions
agent-framework-core: 1.10.0
The issue was reproduced at commit 331d17c5a1e4d17f007559df448e26209f10690f. The relevant enumeration logic remains present on current main commit 0c8bf5b6c0044b8aa4a85c923fb6273a1e144f26.
Python Version
Python 3.13.1
Additional Context
Impact and Prerequisites
An attacker must be able to place a Windows directory junction inside a workspace or store root selected by the application and cause recursive grep to run. Search then reads text files accessible to the agent process outside the configured root. The impact is limited by the operating-system permissions of the agent process.
The configured store root is nevertheless an explicit containment boundary: direct access rejects the escaping path while recursive access returns it.
Suggested Fix
Use one shared link/reparse-point predicate across direct validation, listing, and recursive enumeration:
- Treat
Path.is_junction() as a link on Python versions that provide it.
- On Windows, also reject entries whose
lstat().st_file_attributes contains stat.FILE_ATTRIBUTE_REPARSE_POINT.
- Apply the predicate in
_throw_if_contains_symlink(), _list_sync(), and _enumerate_search_files().
- Resolve or validate every enumerated entry before reading it as defense in depth.
- Add a Windows regression test proving that direct read, listing, and recursive search all reject or skip an NTFS junction targeting a directory outside the store root.
Description
On Windows,
FileSystemAgentFileStoreapplies different root-containment behavior to direct file access and recursive file search.A direct read through an NTFS directory junction is rejected because the resolved target is outside the configured store root. However, recursive
search()enumerates descendants and skips only entries for whichPath.is_symlink()is true. Windows directory junctions returnFalsefromis_symlink(), so recursive search descends the junction and reads matching text files outside the configured root.FileAccessProvider.file_access_grepuses this recursive search path and can therefore return outside-root file names, matching lines, and snippets to the agent.This was previously reported privately to MSRC. MSRC assessed it as Low severity, confirmed that the behavior is being addressed so junctions are treated consistently, shared the report with the responsible team, and closed the case.
Expected Behavior
Direct reads, directory listing, and recursive search should consistently reject or skip Windows junctions and other reparse points. No file outside the configured
FileSystemAgentFileStoreroot should be returned by recursive search.Actual Behavior
The direct read is blocked, but recursive grep returns the outside file:
Root Cause
_resolve_safe_path(), resolves the candidate, and verifies that it remains under the configured root._list_sync()and_enumerate_search_files()skip onlyentry.is_symlink().Path.is_symlink()._search_files_sync()then callsentry.read_text()on the outside-root files.Current affected source:
FileSystemAgentFileStoreroot-containment contract and validation_list_sync()skips only symbolic links_enumerate_search_files()recursively follows junctions_search_files_sync()reads enumerated pathsfile_access_grepexposes recursive search resultsCode Sample
The following local-only reproducer creates temporary directories and a fake canary file. It does not access cloud services, credentials, or real user files.
Run on Windows:
Error Messages / Stack Traces
No unhandled exception is required. The security-relevant inconsistency is that the direct read raises a root-escape error while recursive search successfully returns the same outside-root file.
Package Versions
agent-framework-core:1.10.0The issue was reproduced at commit
331d17c5a1e4d17f007559df448e26209f10690f. The relevant enumeration logic remains present on currentmaincommit0c8bf5b6c0044b8aa4a85c923fb6273a1e144f26.Python Version
Python
3.13.1Additional Context
Impact and Prerequisites
An attacker must be able to place a Windows directory junction inside a workspace or store root selected by the application and cause recursive grep to run. Search then reads text files accessible to the agent process outside the configured root. The impact is limited by the operating-system permissions of the agent process.
The configured store root is nevertheless an explicit containment boundary: direct access rejects the escaping path while recursive access returns it.
Suggested Fix
Use one shared link/reparse-point predicate across direct validation, listing, and recursive enumeration:
Path.is_junction()as a link on Python versions that provide it.lstat().st_file_attributescontainsstat.FILE_ATTRIBUTE_REPARSE_POINT._throw_if_contains_symlink(),_list_sync(), and_enumerate_search_files().