Skip to content

Commit 4b29bb2

Browse files
alliscodeCopilot
andcommitted
Harden FileAccess search and atomic save in store API
- Add wall-clock timeout (10s) around regex scans so a pathological pattern (e.g. `(a+)+`) below the length cap cannot stall the event loop. - Offload the InMemoryAgentFileStore regex scan to a worker thread, matching the filesystem store. - Fail closed when `Path.is_symlink` raises during the safe-path probe so a permission error cannot silently bypass the symlink/reparse-point rejection. - Add `overwrite: bool = True` to `AgentFileStore.write_file`; the in-memory store performs the check under the existing lock and the filesystem store uses `open(mode='x')` so concurrent callers cannot race past `overwrite=False`. - `file_access_save_file` now relies on the atomic store call instead of a separate `file_exists` round-trip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a7fe06c commit 4b29bb2

2 files changed

Lines changed: 159 additions & 39 deletions

File tree

python/packages/core/agent_framework/_harness/_file_access.py

Lines changed: 98 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import os
2626
import re
2727
from abc import ABC, abstractmethod
28-
from collections.abc import Mapping, MutableMapping
28+
from collections.abc import Callable, Mapping, MutableMapping
2929
from pathlib import Path
3030
from typing import Any, cast
3131

@@ -53,11 +53,14 @@
5353

5454
# Hard cap on the length of a user-supplied search regex. Python's ``re`` module
5555
# has no built-in timeout, so a catastrophic-backtracking pattern (such as
56-
# ``(a+)+$``) submitted by the model could block the event loop indefinitely.
57-
# Capping pattern length is a simple, predictable mitigation; pathological
58-
# ReDoS patterns are typically far shorter than this limit, so the cap mostly
59-
# rejects obviously-malformed input while still allowing realistic queries.
56+
# ``(a+)+$``) submitted by the model could spin the CPU indefinitely. The cap
57+
# alone does not stop short pathological patterns, so :meth:`search_files`
58+
# additionally executes the regex scan in a worker thread and bounds the wall
59+
# clock with :data:`_SEARCH_TIMEOUT_SECONDS`. The thread itself cannot be
60+
# safely interrupted from Python, so a runaway scan continues until the
61+
# regex engine returns, but the caller and event loop stay responsive.
6062
_MAX_SEARCH_PATTERN_LENGTH = 256
63+
_SEARCH_TIMEOUT_SECONDS = 10.0
6164

6265

6366
def _compile_search_regex(pattern: str) -> re.Pattern[str]:
@@ -75,6 +78,24 @@ def _compile_search_regex(pattern: str) -> re.Pattern[str]:
7578
return re.compile(pattern, flags=re.IGNORECASE)
7679

7780

81+
async def _run_search_with_timeout(
82+
fn: Callable[[], list[FileSearchResult]],
83+
) -> list[FileSearchResult]:
84+
"""Run ``fn`` in a worker thread with a bounded wall-clock timeout.
85+
86+
Raises:
87+
ValueError: When the search does not complete within
88+
:data:`_SEARCH_TIMEOUT_SECONDS` seconds.
89+
"""
90+
try:
91+
return await asyncio.wait_for(asyncio.to_thread(fn), timeout=_SEARCH_TIMEOUT_SECONDS)
92+
except TimeoutError as exc:
93+
raise ValueError(
94+
f"Regex search did not complete within {_SEARCH_TIMEOUT_SECONDS:g} seconds. "
95+
"Use a more specific pattern (avoid nested quantifiers such as '(a+)+')."
96+
) from exc
97+
98+
7899
def _normalize_relative_path(path: str, *, is_directory: bool = False) -> str:
79100
"""Normalize and validate a relative store path.
80101
@@ -316,12 +337,22 @@ class AgentFileStore(ABC):
316337
"""
317338

318339
@abstractmethod
319-
async def write_file(self, path: str, content: str) -> None:
320-
"""Write ``content`` to the file at ``path``, creating or overwriting it.
340+
async def write_file(self, path: str, content: str, *, overwrite: bool = True) -> None:
341+
"""Write ``content`` to the file at ``path``.
321342
322343
Args:
323344
path: The relative path of the file to write.
324345
content: The content to write to the file.
346+
347+
Keyword Args:
348+
overwrite: When ``True`` (default) any existing file is replaced.
349+
When ``False`` the implementation must perform an atomic
350+
exclusive create and raise :class:`FileExistsError` if a file
351+
already exists at ``path``.
352+
353+
Raises:
354+
FileExistsError: When ``overwrite`` is ``False`` and a file already
355+
exists at ``path``.
325356
"""
326357

327358
@abstractmethod
@@ -413,10 +444,17 @@ def __init__(self) -> None:
413444
def _key(path: str) -> str:
414445
return _normalize_relative_path(path).lower()
415446

416-
async def write_file(self, path: str, content: str) -> None:
417-
"""Write ``content`` to the file at ``path``."""
447+
async def write_file(self, path: str, content: str, *, overwrite: bool = True) -> None:
448+
"""Write ``content`` to the file at ``path``.
449+
450+
When ``overwrite`` is ``False`` the check-and-write happens under the
451+
store lock so concurrent callers cannot both observe a missing file
452+
and race to create it.
453+
"""
418454
key = self._key(path)
419455
async with self._lock:
456+
if not overwrite and key in self._files:
457+
raise FileExistsError(f"File already exists: {path!r}")
420458
self._files[key] = content
421459

422460
async def read_file(self, path: str) -> str | None:
@@ -452,7 +490,12 @@ async def search_files(
452490
regex_pattern: str,
453491
file_pattern: str | None = None,
454492
) -> list[FileSearchResult]:
455-
"""Search file contents for ``regex_pattern`` matches."""
493+
"""Search file contents for ``regex_pattern`` matches.
494+
495+
Snapshots the entries under the store lock and offloads the regex scan
496+
to a worker thread with a bounded timeout so a pathological pattern
497+
cannot stall the event loop.
498+
"""
456499
prefix = _normalize_relative_path(directory, is_directory=True).lower()
457500
if prefix and not prefix.endswith("/"):
458501
prefix += "/"
@@ -461,19 +504,22 @@ async def search_files(
461504
async with self._lock:
462505
entries = list(self._files.items())
463506

464-
results: list[FileSearchResult] = []
465-
for key, file_content in entries:
466-
if not key.startswith(prefix):
467-
continue
468-
relative_name = key[len(prefix) :]
469-
if "/" in relative_name:
470-
continue
471-
if not _matches_glob(relative_name, file_pattern):
472-
continue
473-
result = _search_file_content(relative_name, file_content, regex)
474-
if result is not None:
475-
results.append(result)
476-
return results
507+
def scan() -> list[FileSearchResult]:
508+
results: list[FileSearchResult] = []
509+
for key, file_content in entries:
510+
if not key.startswith(prefix):
511+
continue
512+
relative_name = key[len(prefix) :]
513+
if "/" in relative_name:
514+
continue
515+
if not _matches_glob(relative_name, file_pattern):
516+
continue
517+
result = _search_file_content(relative_name, file_content, regex)
518+
if result is not None:
519+
results.append(result)
520+
return results
521+
522+
return await _run_search_with_timeout(scan)
477523

478524
async def create_directory(self, path: str) -> None:
479525
"""No-op: directories are implicit from file paths in the in-memory store."""
@@ -567,26 +613,38 @@ def _throw_if_contains_symlink(self, candidate: Path) -> None:
567613
for segment in relative_parts:
568614
current = current / segment
569615
try:
570-
if current.is_symlink():
571-
raise ValueError("Invalid path: the resolved path contains a symbolic link or reparse point.")
572-
except OSError:
573-
# Permission errors and similar transient OS errors during the
574-
# symlink probe should not silently allow the access; treat as
575-
# missing and stop checking so the underlying I/O surfaces the
576-
# real error.
577-
break
616+
is_link = current.is_symlink()
617+
except OSError as exc:
618+
# Fail closed: if we cannot verify whether a segment is a
619+
# symlink/reparse point we refuse the operation rather than
620+
# silently allow access that may escape the root.
621+
raise ValueError(
622+
f"Invalid path: unable to verify whether '{segment}' is a symbolic link or reparse point."
623+
) from exc
624+
if is_link:
625+
raise ValueError("Invalid path: the resolved path contains a symbolic link or reparse point.")
578626
if not current.exists():
579627
break
580628

581-
async def write_file(self, path: str, content: str) -> None:
582-
"""Write ``content`` to the file at ``path``."""
629+
async def write_file(self, path: str, content: str, *, overwrite: bool = True) -> None:
630+
"""Write ``content`` to the file at ``path``.
631+
632+
When ``overwrite`` is ``False`` the file is created using ``mode="x"``
633+
so the underlying ``open`` call performs an atomic exclusive create
634+
(``O_EXCL`` on POSIX, ``CREATE_NEW`` on Windows) and raises
635+
:class:`FileExistsError` if a file already exists.
636+
"""
583637
full_path = self._resolve_safe_path(path)
584-
await asyncio.to_thread(self._write_file_sync, full_path, content)
638+
await asyncio.to_thread(self._write_file_sync, full_path, content, overwrite)
585639

586640
@staticmethod
587-
def _write_file_sync(full_path: Path, content: str) -> None:
641+
def _write_file_sync(full_path: Path, content: str, overwrite: bool) -> None:
588642
full_path.parent.mkdir(parents=True, exist_ok=True)
589-
full_path.write_text(content, encoding="utf-8")
643+
if overwrite:
644+
full_path.write_text(content, encoding="utf-8")
645+
return
646+
with full_path.open("x", encoding="utf-8") as handle:
647+
handle.write(content)
590648

591649
async def read_file(self, path: str) -> str | None:
592650
"""Return the file content, or ``None`` if the file does not exist."""
@@ -646,7 +704,7 @@ async def search_files(
646704
"""Search file contents for ``regex_pattern`` matches."""
647705
full_dir = self._resolve_safe_directory_path(directory)
648706
regex = _compile_search_regex(regex_pattern)
649-
return await asyncio.to_thread(self._search_files_sync, full_dir, regex, file_pattern)
707+
return await _run_search_with_timeout(lambda: self._search_files_sync(full_dir, regex, file_pattern))
650708

651709
@staticmethod
652710
def _search_files_sync(full_dir: Path, regex: re.Pattern[str], file_pattern: str | None) -> list[FileSearchResult]:
@@ -736,9 +794,10 @@ async def before_run(
736794
async def file_access_save_file(file_name: str, content: str, overwrite: bool = False) -> str:
737795
"""Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.""" # noqa: E501
738796
normalized = _normalize_relative_path(file_name)
739-
if not overwrite and await self.store.file_exists(normalized):
797+
try:
798+
await self.store.write_file(normalized, content, overwrite=overwrite)
799+
except FileExistsError:
740800
return f"File '{file_name}' already exists. To replace it, save again with overwrite set to true."
741-
await self.store.write_file(normalized, content)
742801
return f"File '{file_name}' saved."
743802

744803
@tool(name="file_access_read_file", approval_mode="never_require")

python/packages/core/tests/core/test_harness_file_access.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
import re
7+
import time
78
from pathlib import Path
89

910
import pytest
@@ -21,11 +22,13 @@
2122
Message,
2223
SupportsChatGetResponse,
2324
)
25+
from agent_framework._harness import _file_access as _file_access_module
2426
from agent_framework._harness._file_access import (
2527
DEFAULT_FILE_ACCESS_INSTRUCTIONS,
2628
DEFAULT_FILE_ACCESS_SOURCE_ID,
2729
_matches_glob,
2830
_normalize_relative_path,
31+
_run_search_with_timeout,
2932
)
3033

3134

@@ -393,6 +396,64 @@ async def test_file_access_provider_accepts_custom_instructions() -> None:
393396
assert provider.source_id == DEFAULT_FILE_ACCESS_SOURCE_ID
394397

395398

399+
async def test_in_memory_store_write_file_raises_when_exists_and_no_overwrite() -> None:
400+
"""The atomic exclusive-create path should raise ``FileExistsError`` under the lock."""
401+
store = InMemoryAgentFileStore()
402+
await store.write_file("plan.md", "v1")
403+
404+
with pytest.raises(FileExistsError):
405+
await store.write_file("plan.md", "v2", overwrite=False)
406+
407+
# The original content is preserved.
408+
assert await store.read_file("plan.md") == "v1"
409+
410+
# Default ``overwrite=True`` still replaces.
411+
await store.write_file("plan.md", "v3")
412+
assert await store.read_file("plan.md") == "v3"
413+
414+
415+
async def test_filesystem_store_write_file_raises_when_exists_and_no_overwrite(tmp_path: Path) -> None:
416+
"""The filesystem store should use exclusive-create semantics when ``overwrite=False``."""
417+
store = FileSystemAgentFileStore(tmp_path)
418+
await store.write_file("plan.md", "v1")
419+
420+
with pytest.raises(FileExistsError):
421+
await store.write_file("plan.md", "v2", overwrite=False)
422+
423+
assert (tmp_path / "plan.md").read_text(encoding="utf-8") == "v1"
424+
425+
await store.write_file("plan.md", "v3", overwrite=True)
426+
assert (tmp_path / "plan.md").read_text(encoding="utf-8") == "v3"
427+
428+
429+
async def test_run_search_with_timeout_raises_value_error(monkeypatch: pytest.MonkeyPatch) -> None:
430+
"""A scan that exceeds the timeout should surface a clean ``ValueError``."""
431+
monkeypatch.setattr(_file_access_module, "_SEARCH_TIMEOUT_SECONDS", 0.01)
432+
433+
def slow() -> list[FileSearchResult]:
434+
time.sleep(0.5)
435+
return []
436+
437+
with pytest.raises(ValueError, match="did not complete"):
438+
await _run_search_with_timeout(slow)
439+
440+
441+
async def test_filesystem_store_symlink_probe_fails_closed_on_oserror(
442+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
443+
) -> None:
444+
"""If ``Path.is_symlink`` raises during the probe, the operation must be refused."""
445+
store = FileSystemAgentFileStore(tmp_path)
446+
await store.write_file("ok.txt", "content")
447+
448+
def boom(self: Path) -> bool:
449+
raise PermissionError("access denied")
450+
451+
monkeypatch.setattr(Path, "is_symlink", boom)
452+
453+
with pytest.raises(ValueError, match="symbolic link or reparse point"):
454+
await store.read_file("ok.txt")
455+
456+
396457
def test_file_access_harness_classes_are_marked_experimental() -> None:
397458
"""File-access harness public classes should expose HARNESS experimental metadata."""
398459
assert AgentFileStore.__feature_id__ == ExperimentalFeature.HARNESS.value

0 commit comments

Comments
 (0)