Skip to content

Python: [Bug]: FileSystemAgentFileStore recursive search follows Windows junctions outside its configured root #7290

Description

@sricursion

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:

  1. Treat Path.is_junction() as a link on Python versions that provide it.
  2. On Windows, also reject entries whose lstat().st_file_attributes contains stat.FILE_ATTRIBUTE_REPARSE_POINT.
  3. Apply the predicate in _throw_if_contains_symlink(), _list_sync(), and _enumerate_search_files().
  4. Resolve or validate every enumerated entry before reading it as defense in depth.
  5. 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.

Metadata

Metadata

Labels

pythonUsage: [Issues, PRs], Target: Python

Type

No type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions