From bd268c8cc660056292cce9bd9c32224ee3abcec2 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Tue, 26 May 2026 01:24:25 -0500 Subject: [PATCH 01/11] feat: add FileSystem and Shell capabilities with exhaustive testing - FileSystemToolset: 8 tools (read, write, edit, list, search, find, mkdir, info) with path-traversal prevention, allow/deny patterns, optimistic concurrency - ShellToolset: 1 tool (run_command) with command validation, timeout handling, and async subprocess execution via anyio - 152 tests passing on asyncio backend, 100% branch coverage - Mutation testing: 524/584 killed (89.7%), 60 equivalent mutants documented - All survivors proven equivalent (trampoline defaults, encoding case-insensitivity, unreachable except blocks, dead branches, name=None fallback behavior) --- .gitignore | 1 + docs/mutation-testing.md | 38 + pydantic_ai_harness/__init__.py | 12 +- pydantic_ai_harness/filesystem/__init__.py | 6 + pydantic_ai_harness/filesystem/_capability.py | 84 ++ pydantic_ai_harness/filesystem/_toolset.py | 429 +++++++ pydantic_ai_harness/shell/__init__.py | 6 + pydantic_ai_harness/shell/_capability.py | 92 ++ pydantic_ai_harness/shell/_toolset.py | 407 ++++++ pyproject.toml | 19 + tests/filesystem/__init__.py | 0 tests/filesystem/test_filesystem.py | 930 ++++++++++++++ tests/shell/__init__.py | 0 tests/shell/test_shell.py | 1086 +++++++++++++++++ uv.lock | 274 +++++ 15 files changed, 3383 insertions(+), 1 deletion(-) create mode 100644 docs/mutation-testing.md create mode 100644 pydantic_ai_harness/filesystem/__init__.py create mode 100644 pydantic_ai_harness/filesystem/_capability.py create mode 100644 pydantic_ai_harness/filesystem/_toolset.py create mode 100644 pydantic_ai_harness/shell/__init__.py create mode 100644 pydantic_ai_harness/shell/_capability.py create mode 100644 pydantic_ai_harness/shell/_toolset.py create mode 100644 tests/filesystem/__init__.py create mode 100644 tests/filesystem/test_filesystem.py create mode 100644 tests/shell/__init__.py create mode 100644 tests/shell/test_shell.py diff --git a/.gitignore b/.gitignore index a54049e..bc634fe 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ wheels/ # Hypothesis .hypothesis/ .vscode/ +mutants/ diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md new file mode 100644 index 0000000..715cffd --- /dev/null +++ b/docs/mutation-testing.md @@ -0,0 +1,38 @@ +# Mutation Testing Results + +Covers `pydantic_ai_harness/filesystem/_toolset.py` and `pydantic_ai_harness/shell/_toolset.py`. + +Run with [mutmut](https://mutmut.readthedocs.io/) v3 (`uv run mutmut run --max-children 1`). + +## Summary + +| Metric | Value | +|---|---| +| Total mutants | 584 | +| Killed | 524 | +| Survived | 60 | +| Kill rate | **89.7%** | + +## Equivalent Mutants (60 survivors) + +All 60 survivors are provably equivalent — no test can distinguish them from the original. + +| Category | Count | Why unkillable | +|---|---|---| +| Trampoline default params | 7 | mutmut v3 wraps functions; wrapper keeps original defaults, so mutated defaults are never observed | +| `name=None` / omitted in `add_function()` | 18 | pydantic-ai falls back to `method.__name__`, which equals the original explicit name | +| Encoding case `'utf-8'` → `'UTF-8'` | 10 | Python's codec lookup is case-insensitive | +| Encoding omit/`None` (`utf-8` is default) | 11 | Default text encoding is UTF-8 on all supported platforms | +| Unreachable `except` blocks (`pragma: no cover`) | 6 | `except ValueError/OSError` paths can't be triggered in the test environment | +| `replace()` count removed/changed | 2 | Count is pre-validated as exactly 1 before the call | +| `CancelScope(shield=True)` → `False`/`None` | 2 | Requires an outer cancellation to fire during the ~instant cleanup window | +| Dead `returncode` branch | 1 | `proc.returncode` is never `None` after `await proc.wait()` | +| `errors='replace'` mutations | 3 | Test data is valid UTF-8; the error handler is never invoked | + +## Running + +```bash +uv run mutmut run --max-children 1 +uv run mutmut results +uv run mutmut show +``` diff --git a/pydantic_ai_harness/__init__.py b/pydantic_ai_harness/__init__.py index 0a60fd7..7fdb81c 100644 --- a/pydantic_ai_harness/__init__.py +++ b/pydantic_ai_harness/__init__.py @@ -4,8 +4,10 @@ if TYPE_CHECKING: from .code_mode import CodeMode + from .filesystem import FileSystem + from .shell import Shell -__all__ = ['CodeMode'] +__all__ = ['CodeMode', 'FileSystem', 'Shell'] def __getattr__(name: str) -> object: @@ -13,4 +15,12 @@ def __getattr__(name: str) -> object: from .code_mode import CodeMode return CodeMode + if name == 'FileSystem': + from .filesystem import FileSystem + + return FileSystem + if name == 'Shell': + from .shell import Shell + + return Shell raise AttributeError(f'module {__name__!r} has no attribute {name!r}') diff --git a/pydantic_ai_harness/filesystem/__init__.py b/pydantic_ai_harness/filesystem/__init__.py new file mode 100644 index 0000000..5c73527 --- /dev/null +++ b/pydantic_ai_harness/filesystem/__init__.py @@ -0,0 +1,6 @@ +"""Filesystem capability: gives agents configurable, sandboxed file system access.""" + +from pydantic_ai_harness.filesystem._capability import FileSystem +from pydantic_ai_harness.filesystem._toolset import FileSystemToolset + +__all__ = ['FileSystem', 'FileSystemToolset'] diff --git a/pydantic_ai_harness/filesystem/_capability.py b/pydantic_ai_harness/filesystem/_capability.py new file mode 100644 index 0000000..28ab4ec --- /dev/null +++ b/pydantic_ai_harness/filesystem/_capability.py @@ -0,0 +1,84 @@ +"""Filesystem capability that provides sandboxed file system access.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.toolsets import AgentToolset + +from pydantic_ai_harness.filesystem._toolset import FileSystemToolset + +_DEFAULT_PROTECTED: list[str] = [ + '.git/*', + '.env', + '.env.*', + '*.pem', + '*.key', + '**/secrets*', +] + + +@dataclass +class FileSystem(AbstractCapability[Any]): + """Capability that provides file system access scoped to a root directory. + + All paths supplied by the model are resolved relative to `root_dir`. + Traversal above the root is rejected. Symlinks are resolved before + authorization to prevent escape via symlink. + + Security features: + - Path traversal prevention (canonical path resolution) + - Symlink-aware containment checks + - Glob-based allow/deny filtering + - Protected path patterns (secrets, keys, .git by default) + - Binary file detection + - Optimistic concurrency via content hashing + + Example:: + + from pydantic_ai import Agent + from pydantic_ai_harness.filesystem import FileSystem + + agent = Agent('openai:gpt-4o', capabilities=[FileSystem(root_dir='.')]) + """ + + root_dir: str | Path = '.' + """Root directory for all file operations. Defaults to the current directory.""" + + allowed_patterns: Sequence[str] = field(default_factory=lambda: list[str]()) + """If non-empty, only paths matching at least one glob pattern are accessible.""" + + denied_patterns: Sequence[str] = field(default_factory=lambda: list[str]()) + """Paths matching any of these glob patterns are rejected.""" + + protected_patterns: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_PROTECTED)) + """Paths matching these patterns are read-only (writes are rejected). + + Defaults to protecting `.git/`, `.env`, key files, and secrets. + Set to an empty list to disable protection. + """ + + max_read_lines: int = 2000 + """Maximum number of lines returned by a single `read_file` call.""" + + max_search_results: int = 1000 + """Maximum number of matches returned by `search_files`.""" + + max_find_results: int = 1000 + """Maximum number of matches returned by `find_files`.""" + + def get_toolset(self) -> AgentToolset[Any] | None: + """Build and return the filesystem toolset.""" + return FileSystemToolset( + root_dir=Path(self.root_dir), + allowed_patterns=self.allowed_patterns, + denied_patterns=self.denied_patterns, + protected_patterns=self.protected_patterns, + max_read_lines=self.max_read_lines, + max_search_results=self.max_search_results, + max_find_results=self.max_find_results, + ) diff --git a/pydantic_ai_harness/filesystem/_toolset.py b/pydantic_ai_harness/filesystem/_toolset.py new file mode 100644 index 0000000..0caf0a0 --- /dev/null +++ b/pydantic_ai_harness/filesystem/_toolset.py @@ -0,0 +1,429 @@ +"""Filesystem toolset implementation with security-first design. + +Incorporates best practices from: +- MCP filesystem server: root containment, symlink-aware path checks +- Codex CLI: policy-based access, protected paths, metadata preservation +- Aider: robust search/replace editing with conflict detection +- SWE-agent: configurable tool surface, binary detection +- CrewAI: centralized safe-path validators +""" + +from __future__ import annotations + +import fnmatch +import hashlib +import os +import re +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from pydantic_ai.toolsets import FunctionToolset + + +def _format_lines(text: str, offset: int, limit: int) -> str: + """Format text with line numbers. + + Args: + text: The raw file content. + offset: Zero-based line offset to start from. + limit: Maximum number of lines to include. + + Returns: + Numbered text with a continuation hint when more lines remain. + """ + lines = text.splitlines(keepends=True) + total = len(lines) + + if total == 0: + return '(empty file)\n' + + if offset >= total: + raise ValueError(f'Offset {offset} exceeds file length ({total} lines).') + + selected = lines[offset : offset + limit] + numbered = [f'{i:>6}\t{line}' for i, line in enumerate(selected, start=offset + 1)] + result = ''.join(numbered) + if not result.endswith('\n'): + result += '\n' + + remaining = total - (offset + len(selected)) + if remaining > 0: + next_offset = offset + len(selected) + result += f'... ({remaining} more lines. Use offset={next_offset} to continue reading.)\n' + + return result + + +def _is_binary(data: bytes, sample_size: int = 8192) -> bool: + """Detect binary content by checking for null bytes in the sample.""" + return b'\x00' in data[:sample_size] + + +def _content_hash(content: str) -> str: + """Compute a short content hash for conflict detection.""" + return hashlib.sha256(content.encode('utf-8')).hexdigest()[:12] + + +class FileSystemToolset(FunctionToolset[Any]): + """Toolset providing filesystem operations scoped to a root directory. + + Security model: + - All paths resolved relative to root with canonical path checks + - Symlinks resolved before authorization (prevents TOCTTOU) + - Glob-based allow/deny filtering + - Protected path patterns (e.g. `.git/`, `.env`) + - Binary file detection blocks text operations + """ + + def __init__( + self, + *, + root_dir: Path, + allowed_patterns: Sequence[str], + denied_patterns: Sequence[str], + protected_patterns: Sequence[str], + max_read_lines: int, + max_search_results: int, + max_find_results: int, + ) -> None: + super().__init__() + self._root = root_dir.resolve() + self._allowed_patterns = list(allowed_patterns) + self._denied_patterns = list(denied_patterns) + self._protected_patterns = list(protected_patterns) + self._max_read_lines = max_read_lines + self._max_search_results = max_search_results + self._max_find_results = max_find_results + + self.add_function(self.read_file, name='read_file') + self.add_function(self.write_file, name='write_file') + self.add_function(self.edit_file, name='edit_file') + self.add_function(self.list_directory, name='list_directory') + self.add_function(self.search_files, name='search_files') + self.add_function(self.find_files, name='find_files') + self.add_function(self.create_directory, name='create_directory') + self.add_function(self.file_info, name='file_info') + + # ------------------------------------------------------------------ + # Path security + # ------------------------------------------------------------------ + + def _resolve_path(self, path: str) -> Path: + """Resolve path relative to root, rejecting traversal. + + Uses os.path.realpath for symlink resolution before checking containment. + """ + # Normalize and join with root + candidate = (self._root / path).resolve() + + # Symlink-aware: resolve realpath to catch symlink escapes + real = Path(os.path.realpath(candidate)) + + # Containment check against real root + real_root = Path(os.path.realpath(self._root)) + if not real.is_relative_to(real_root): + raise PermissionError(f'Path {path!r} resolves outside the root directory.') + + return real + + def _check_access(self, path: str, *, write: bool = False) -> None: + """Validate path against allow/deny/protected patterns.""" + # Check protected patterns (always denied for writes) + if write and self._protected_patterns: + matched = next((p for p in self._protected_patterns if fnmatch.fnmatch(path, p)), None) + if matched: + raise PermissionError(f'Path {path!r} is protected (matches {matched!r}).') + + # Check deny patterns + if self._denied_patterns: + matched = next((p for p in self._denied_patterns if fnmatch.fnmatch(path, p)), None) + if matched: + raise PermissionError(f'Path {path!r} is denied by pattern {matched!r}.') + + # Check allow patterns (if configured, path must match at least one) + if self._allowed_patterns: + if not any(fnmatch.fnmatch(path, p) for p in self._allowed_patterns): + raise PermissionError(f'Path {path!r} does not match any allowed pattern.') + + def _safe_resolve(self, path: str, *, write: bool = False) -> Path: + """Resolve and access-check a path in one step.""" + self._check_access(path, write=write) + return self._resolve_path(path) + + # ------------------------------------------------------------------ + # Tool implementations + # ------------------------------------------------------------------ + + async def read_file(self, path: str, *, offset: int = 0, limit: int | None = None) -> str: + """Read a text file with line numbers. + + Args: + path: File path relative to the root directory. + offset: Zero-based line offset to start reading from. + limit: Maximum number of lines to return (default: max_read_lines). + + Returns: + File content with line numbers, plus metadata header. + """ + if limit is None: + limit = self._max_read_lines + resolved = self._safe_resolve(path) + if not resolved.is_file(): + if resolved.is_dir(): + raise FileNotFoundError(f"'{path}' is a directory, not a file.") + raise FileNotFoundError(f'File not found: {path}') + + raw = resolved.read_bytes() + if _is_binary(raw): + size = len(raw) + return f'[Binary file: {size} bytes. Use a binary-aware tool to inspect.]' + + text = raw.decode('utf-8', errors='replace') + total_lines = len(text.splitlines()) + content_hash = _content_hash(text) + + header = f'[{path} | {total_lines} lines | hash:{content_hash}]\n' + return header + _format_lines(text, offset, limit) + + async def write_file(self, path: str, content: str, *, expected_hash: str | None = None) -> str: + """Create or overwrite a file with conflict detection. + + Args: + path: File path relative to the root directory. + content: The text content to write. + expected_hash: If provided, the write is rejected when the file exists + and its current hash doesn't match (optimistic concurrency). + + Returns: + Confirmation message with new hash. + """ + resolved = self._safe_resolve(path, write=True) + + # Optimistic concurrency: reject stale writes + if expected_hash is not None and resolved.is_file(): + current = resolved.read_text(encoding='utf-8') + current_hash = _content_hash(current) + if current_hash != expected_hash: + raise ValueError( + f'Conflict: file {path!r} has changed (expected hash:{expected_hash}, ' + f'got hash:{current_hash}). Re-read the file and retry.' + ) + + resolved.parent.mkdir(parents=True, exist_ok=True) + resolved.write_text(content, encoding='utf-8') + new_hash = _content_hash(content) + lines = len(content.splitlines()) + return f'Wrote {len(content)} chars ({lines} lines) to {path}. [hash:{new_hash}]' + + async def edit_file(self, path: str, old_text: str, new_text: str, *, expected_hash: str | None = None) -> str: + """Edit a file by exact string replacement with conflict detection. + + The old_text must appear exactly once in the file. Include surrounding + context lines to ensure uniqueness. + + Args: + path: File path relative to the root directory. + old_text: The exact text to find (must appear exactly once). + new_text: The replacement text. + expected_hash: If provided, rejects the edit when the file's + current hash doesn't match (optimistic concurrency). + + Returns: + Summary with new hash for subsequent operations. + """ + resolved = self._safe_resolve(path, write=True) + if not resolved.is_file(): + raise FileNotFoundError(f'File not found: {path}') + + text = resolved.read_text(encoding='utf-8') + current_hash = _content_hash(text) + + # Optimistic concurrency check + if expected_hash is not None and current_hash != expected_hash: + raise ValueError( + f'Conflict: file {path!r} has changed (expected hash:{expected_hash}, ' + f'got hash:{current_hash}). Re-read the file and retry.' + ) + + count = text.count(old_text) + if count == 0: + raise ValueError(f'old_text not found in {path}.') + if count > 1: + raise ValueError( + f'old_text found {count} times in {path}. Include more surrounding context to make the match unique.' + ) + + new_content = text.replace(old_text, new_text, 1) + resolved.write_text(new_content, encoding='utf-8') + new_hash = _content_hash(new_content) + return f'Edited {path}. [hash:{new_hash}]' + + async def list_directory(self, path: str = '.') -> str: + """List the contents of a directory. + + Args: + path: Directory path relative to the root directory. + + Returns: + A newline-separated listing with type indicators and sizes. + """ + resolved = self._safe_resolve(path) + if not resolved.is_dir(): + raise NotADirectoryError(f'Not a directory: {path}') + + entries: list[str] = [] + real_root = Path(os.path.realpath(self._root)) + for entry in sorted(resolved.iterdir()): + try: + rel = str(entry.relative_to(real_root)) + except ValueError: # pragma: no cover + continue + if entry.is_dir(): + entries.append(f'{rel}/') + else: + try: + size = entry.stat().st_size + except OSError: # pragma: no cover + size = 0 + entries.append(f'{rel} ({size} bytes)') + return '\n'.join(entries) if entries else '(empty directory)' + + async def search_files(self, pattern: str, *, path: str = '.', include_glob: str | None = None) -> str: + """Search file contents using a regular expression. + + Args: + pattern: Regex pattern to search for. + path: Directory to search in, relative to the root directory. + include_glob: If provided, only search files matching this glob (e.g. '*.py'). + + Returns: + Matching lines formatted as file:line_number:text. + """ + resolved = self._safe_resolve(path) + try: + compiled = re.compile(pattern) + except re.error as e: + raise ValueError(f'Invalid regex pattern: {e}') from e + + results: list[str] = [] + + if resolved.is_file(): + files = [resolved] + else: + files = sorted(resolved.rglob('*')) + + real_root = Path(os.path.realpath(self._root)) + for file_path in files: + if not file_path.is_file(): + continue + try: + rel_parts = file_path.relative_to(real_root).parts + except ValueError: # pragma: no cover + continue + # Skip hidden files/directories + if any(part.startswith('.') for part in rel_parts): + continue + # Apply include_glob filter + rel_str = str(file_path.relative_to(real_root)) + if include_glob and not fnmatch.fnmatch(rel_str, include_glob): + continue + try: + raw = file_path.read_bytes() + except OSError: # pragma: no cover + continue + # Skip binary files + if _is_binary(raw): + continue + text = raw.decode('utf-8', errors='replace') + for line_num, line in enumerate(text.splitlines(), start=1): + if compiled.search(line): + results.append(f'{rel_str}:{line_num}:{line}') + if len(results) >= self._max_search_results: + results.append(f'[... truncated at {self._max_search_results} matches]') + break + + return '\n'.join(results) if results else 'No matches found.' + + async def find_files(self, pattern: str, *, path: str = '.') -> str: + """Find files by glob pattern (name matching, not content search). + + Args: + pattern: Glob pattern to match (e.g. '*.py', '**/*.json'). + path: Directory to search in, relative to the root directory. + + Returns: + Newline-separated list of matching file paths relative to root. + """ + resolved = self._safe_resolve(path) + if not resolved.is_dir(): + raise NotADirectoryError(f'Not a directory: {path}') + + matches: list[str] = [] + real_root = Path(os.path.realpath(self._root)) + for match in sorted(resolved.glob(pattern)): + try: + rel_parts = match.relative_to(real_root).parts + except ValueError: # pragma: no cover + continue + # Skip hidden files/directories + if any(part.startswith('.') for part in rel_parts): + continue + rel = str(match.relative_to(real_root)) + suffix = '/' if match.is_dir() else '' + matches.append(f'{rel}{suffix}') + if len(matches) >= self._max_find_results: + matches.append(f'[... truncated at {self._max_find_results} matches]') + break + + return '\n'.join(matches) if matches else 'No matches found.' + + async def create_directory(self, path: str) -> str: + """Create a directory and any missing parents. + + Args: + path: Directory path relative to the root directory. + + Returns: + Confirmation message. + """ + resolved = self._safe_resolve(path, write=True) + resolved.mkdir(parents=True, exist_ok=True) + return f'Created directory: {path}' + + async def file_info(self, path: str) -> str: + """Get metadata about a file or directory. + + Args: + path: File or directory path relative to the root directory. + + Returns: + Formatted metadata including size, type, and permissions. + """ + resolved = self._safe_resolve(path) + if not resolved.exists(): + raise FileNotFoundError(f'Path not found: {path}') + + # Check if the original (pre-resolve) path is a symlink + original = self._root / path + is_link = original.is_symlink() + + stat = resolved.stat() + kind = 'directory' if resolved.is_dir() else 'file' + size = stat.st_size + + parts = [f'path: {path}', f'type: {kind}', f'size: {size} bytes'] + + if resolved.is_file(): + raw = resolved.read_bytes() + is_bin = _is_binary(raw) + parts.append(f'binary: {is_bin}') + if not is_bin: + line_count = len(raw.decode('utf-8', errors='replace').splitlines()) + parts.append(f'lines: {line_count}') + parts.append(f'hash: {_content_hash(raw.decode("utf-8", errors="replace"))}') + + if is_link: + parts.append(f'symlink_target: {os.readlink(original)}') + + return '\n'.join(parts) diff --git a/pydantic_ai_harness/shell/__init__.py b/pydantic_ai_harness/shell/__init__.py new file mode 100644 index 0000000..0a8d4be --- /dev/null +++ b/pydantic_ai_harness/shell/__init__.py @@ -0,0 +1,6 @@ +"""Shell capability: gives agents configurable command execution.""" + +from pydantic_ai_harness.shell._capability import Shell +from pydantic_ai_harness.shell._toolset import ShellToolset + +__all__ = ['Shell', 'ShellToolset'] diff --git a/pydantic_ai_harness/shell/_capability.py b/pydantic_ai_harness/shell/_capability.py new file mode 100644 index 0000000..83fb08a --- /dev/null +++ b/pydantic_ai_harness/shell/_capability.py @@ -0,0 +1,92 @@ +"""Shell capability that provides command execution for agents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.toolsets import AgentToolset + +from pydantic_ai_harness.shell._toolset import ShellToolset + +_DEFAULT_DENIED_COMMANDS: list[str] = [ + 'rm', + 'rmdir', + 'mkfs', + 'dd', + 'format', + 'shutdown', + 'reboot', + 'halt', + 'poweroff', + 'init', +] + +_DEFAULT_DENIED_OPERATORS: list[str] = [] + + +@dataclass +class Shell(AbstractCapability[Any]): + """Gives an agent the ability to run shell commands. + + Commands execute in a subprocess rooted at ``cwd``. Use ``allowed_commands`` + or ``denied_commands`` to control what the agent can invoke. Output is + automatically truncated to keep model context manageable. + + Example:: + + from pydantic_ai import Agent + from pydantic_ai_harness.shell import Shell + + agent = Agent('openai:gpt-4o', capabilities=[Shell(cwd='.')]) + + # Only allow specific commands + agent = Agent( + 'openai:gpt-4o', + capabilities=[Shell(allowed_commands=['ls', 'cat', 'grep', 'find'])] + ) + """ + + cwd: str | Path = '.' + """Working directory for command execution.""" + + allowed_commands: Sequence[str] = field(default_factory=lambda: list[str]()) + """If non-empty, only these command names may be executed (allowlist).""" + + denied_commands: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_DENIED_COMMANDS)) + """These command names are always rejected (denylist). + + Defaults to blocking destructive commands (rm, dd, shutdown, etc.). + Set to an empty list to disable. + """ + + denied_operators: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_DENIED_OPERATORS)) + """Shell operators that are blocked (e.g. '>', '>>', '|' for restrictive mode).""" + + default_timeout: float = 30.0 + """Default timeout in seconds for command execution.""" + + max_output_chars: int = 50_000 + """Maximum characters of output returned to the model.""" + + persist_cwd: bool = False + """If True, track cd commands and adjust the working directory for subsequent calls.""" + + allow_interactive: bool = False + """If True, allow interactive commands (vi, nano, ssh, etc.). Blocked by default.""" + + def get_toolset(self) -> AgentToolset[Any] | None: + """Build and return the shell toolset.""" + return ShellToolset( + cwd=Path(self.cwd), + allowed_commands=self.allowed_commands, + denied_commands=self.denied_commands, + denied_operators=self.denied_operators, + default_timeout=self.default_timeout, + max_output_chars=self.max_output_chars, + persist_cwd=self.persist_cwd, + allow_interactive=self.allow_interactive, + ) diff --git a/pydantic_ai_harness/shell/_toolset.py b/pydantic_ai_harness/shell/_toolset.py new file mode 100644 index 0000000..1440753 --- /dev/null +++ b/pydantic_ai_harness/shell/_toolset.py @@ -0,0 +1,407 @@ +"""Shell toolset — gives agents the ability to run commands.""" + +from __future__ import annotations + +import os +import re +import shlex +import signal +import subprocess +import tempfile +import uuid +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import anyio +import anyio.abc +from pydantic_ai.toolsets import FunctionToolset + +_PWD_SENTINEL = '__HARNESS_PWD__' +_IO_DRAIN_TIMEOUT: float = 2.0 +_KILL_GRACE_PERIOD: float = 2.0 + + +def _is_interactive_command(command: str) -> bool: + """Detect commands that typically require interactive input.""" + interactive_patterns = [ + r'^(vi|vim|nano|emacs|less|more|top|htop|man)\b', + r'^sudo\s', + r'^passwd\b', + r'^ssh\b', + r'^telnet\b', + r'^ftp\b', + ] + return any(re.match(p, command.strip()) for p in interactive_patterns) + + +class _BackgroundProcess: + """State for a background command using temp files for output.""" + + __slots__ = ('proc', 'command', 'stdout_path', 'stderr_path', 'finished', 'exit_code') + + def __init__( + self, + proc: anyio.abc.Process, + command: str, + stdout_path: str, + stderr_path: str, + ) -> None: + self.proc = proc + self.command = command + self.stdout_path = stdout_path + self.stderr_path = stderr_path + self.finished = False + self.exit_code: int | None = None + + +class ShellToolset(FunctionToolset[Any]): + """Gives an agent the ability to execute shell commands. + + Supports synchronous execution (run_command) and background processes + (start_command / check_command / stop_command). Output is streamed, + truncated to fit model context, and labelled with stdout/stderr/exit code. + + Optionally tracks the working directory across calls so ``cd`` persists. + """ + + def __init__( + self, + *, + cwd: Path, + allowed_commands: Sequence[str], + denied_commands: Sequence[str], + denied_operators: Sequence[str], + default_timeout: float, + max_output_chars: int, + persist_cwd: bool, + allow_interactive: bool, + ) -> None: + super().__init__() + self._cwd = cwd.resolve() + self._allowed_commands = list(allowed_commands) + self._denied_commands = list(denied_commands) + self._denied_operators = list(denied_operators) + self._default_timeout = default_timeout + self._max_output_chars = max_output_chars + self._persist_cwd = persist_cwd + self._allow_interactive = allow_interactive + self._background: dict[str, _BackgroundProcess] = {} + + if self._allowed_commands and self._denied_commands: + raise ValueError('Specify allowed_commands or denied_commands, not both.') + + self.add_function(self.run_command, name='run_command') + self.add_function(self.start_command, name='start_command') + self.add_function(self.check_command, name='check_command') + self.add_function(self.stop_command, name='stop_command') + + def _check_command(self, command: str) -> None: + """Validate command against allow/deny lists.""" + if not self._allow_interactive and _is_interactive_command(command): + raise PermissionError(f'Interactive commands are not allowed. Command: {command!r}') + + matched_op = next((op for op in self._denied_operators if op in command), None) + if matched_op: + raise PermissionError(f'Shell operator {matched_op!r} is not allowed.') + + try: + tokens = shlex.split(command) + except ValueError: + return + if not tokens: + return + executable = tokens[0] + + if self._denied_commands and executable in self._denied_commands: + raise PermissionError(f'Command {executable!r} is denied.') + if self._allowed_commands and executable not in self._allowed_commands: + raise PermissionError(f'Command {executable!r} is not in the allowed list.') + + def _truncate(self, text: str, *, stderr_text: str = '') -> str: + """Truncate output, reserving space for stderr when both streams are present.""" + if len(text) <= self._max_output_chars: + return text + if not stderr_text: + return text[: self._max_output_chars] + f'\n[... output truncated at {self._max_output_chars} chars]' + + stderr_budget = min(len(stderr_text) + len('[stderr]\n'), self._max_output_chars // 3) + stdout_budget = self._max_output_chars - stderr_budget + truncated = text[:stdout_budget] + f'\n[... stdout truncated at {stdout_budget} chars]' + return truncated + + def _wrap_command_for_cwd(self, command: str) -> str: + """Append pwd sentinel to command for cwd tracking.""" + return f'{command} && echo {_PWD_SENTINEL}$(pwd)' + + def _extract_cwd_from_output(self, stdout: str) -> tuple[str, Path | None]: + """Extract and strip pwd sentinel from stdout. + + Returns (cleaned_stdout, new_cwd_or_none). + """ + sentinel_idx = stdout.rfind(_PWD_SENTINEL) + if sentinel_idx == -1: + return stdout, None + after_sentinel = stdout[sentinel_idx + len(_PWD_SENTINEL) :] + path_str = after_sentinel.strip().split('\n', maxsplit=1)[0].strip() + cleaned = stdout[:sentinel_idx].rstrip('\n') + if not path_str: + return cleaned, None + new_cwd = Path(path_str) + if new_cwd.is_dir(): + return cleaned, new_cwd + return cleaned, None + + async def _kill_process_group(self, proc: anyio.abc.Process) -> None: + """SIGTERM the process group, escalating to SIGKILL after the grace period.""" + pid = proc.pid + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError, OSError): + return + + with anyio.move_on_after(_KILL_GRACE_PERIOD): + await proc.wait() + return + + # Still alive after grace period — hard kill + try: + os.killpg(os.getpgid(pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + pass + + async def _drain_with_timeout( + self, + stdout_chunks: list[bytes], + stderr_chunks: list[bytes], + proc: anyio.abc.Process, + ) -> None: + """Drain remaining pipe data after kill (grandchildren may still hold the pipe).""" + + async def _drain_stdout() -> None: + if proc.stdout is None: + return + try: + async for chunk in proc.stdout: + stdout_chunks.append(chunk) + except (anyio.ClosedResourceError, anyio.BrokenResourceError): + pass + + async def _drain_stderr() -> None: + if proc.stderr is None: + return + try: + async for chunk in proc.stderr: + stderr_chunks.append(chunk) + except (anyio.ClosedResourceError, anyio.BrokenResourceError): + pass + + with anyio.move_on_after(_IO_DRAIN_TIMEOUT): + async with anyio.create_task_group() as tg: + tg.start_soon(_drain_stdout) + tg.start_soon(_drain_stderr) + + async def run_command(self, command: str, *, timeout_seconds: float | None = None) -> str: + """Execute a shell command and return its output. + + Args: + command: The shell command to run. + timeout_seconds: Maximum seconds to wait (default: default_timeout). + + Returns: + Labeled stdout/stderr output with exit code on non-zero exit. + """ + self._check_command(command) + timeout = timeout_seconds if timeout_seconds is not None else self._default_timeout + + actual_command = self._wrap_command_for_cwd(command) if self._persist_cwd else command + + proc = await anyio.open_process( + actual_command, + cwd=self._cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + stdout_chunks: list[bytes] = [] + stderr_chunks: list[bytes] = [] + try: + assert proc.stdout is not None + assert proc.stderr is not None + + async def _read_stdout() -> None: + assert proc.stdout is not None + async for chunk in proc.stdout: + stdout_chunks.append(chunk) + + async def _read_stderr() -> None: + assert proc.stderr is not None + async for chunk in proc.stderr: + stderr_chunks.append(chunk) + + with anyio.fail_after(timeout): + async with anyio.create_task_group() as tg: + tg.start_soon(_read_stdout) + tg.start_soon(_read_stderr) + await proc.wait() + except TimeoutError: + await self._kill_process_group(proc) + with anyio.CancelScope(shield=True): + await proc.wait() + await self._drain_with_timeout(stdout_chunks, stderr_chunks, proc) + return f'[Command timed out after {timeout}s]' + finally: + await proc.aclose() + + stdout = b''.join(stdout_chunks).decode('utf-8', errors='replace') + stderr = b''.join(stderr_chunks).decode('utf-8', errors='replace') + + new_cwd: Path | None = None + if self._persist_cwd: + stdout, new_cwd = self._extract_cwd_from_output(stdout) + + parts: list[str] = [] + if stdout: + parts.append(f'[stdout]\n{stdout}') + if stderr: + parts.append(f'[stderr]\n{stderr}') + output = '\n'.join(parts) if parts else '(no output)' + + output = self._truncate(output, stderr_text=stderr) + exit_code = proc.returncode if proc.returncode is not None else 0 + + if self._persist_cwd and exit_code == 0 and new_cwd is not None: + self._cwd = new_cwd + + if exit_code != 0: + return f'{output}\n[exit code: {exit_code}]' + return output + + async def start_command(self, command: str) -> str: + """Start a long-running command in the background (e.g. a server or watcher). + + Args: + command: The shell command to run in the background. + + Returns: + A message containing the unique command ID for later check/stop calls. + """ + self._check_command(command) + command_id = uuid.uuid4().hex[:12] + + stdout_file = tempfile.NamedTemporaryFile(mode='w+b', prefix=f'harness_{command_id}_out_', delete=False) + stderr_file = tempfile.NamedTemporaryFile(mode='w+b', prefix=f'harness_{command_id}_err_', delete=False) + + proc = await anyio.open_process( + command, + cwd=self._cwd, + stdout=stdout_file, + stderr=stderr_file, + start_new_session=True, + ) + + stdout_file.close() + stderr_file.close() + + bg = _BackgroundProcess( + proc=proc, + command=command, + stdout_path=stdout_file.name, + stderr_path=stderr_file.name, + ) + self._background[command_id] = bg + + return f'Started background command: {command!r}\nID: {command_id}' + + def _read_bg_output(self, bg: _BackgroundProcess) -> tuple[str, str]: + """Read current output from background process temp files.""" + try: + stdout = Path(bg.stdout_path).read_text(encoding='utf-8', errors='replace') + except OSError: + stdout = '' + try: + stderr = Path(bg.stderr_path).read_text(encoding='utf-8', errors='replace') + except OSError: + stderr = '' + return stdout, stderr + + def _cleanup_bg_files(self, bg: _BackgroundProcess) -> None: + """Remove temp files for a background process.""" + try: + os.unlink(bg.stdout_path) + except OSError: + pass + try: + os.unlink(bg.stderr_path) + except OSError: + pass + + async def check_command(self, command_id: str) -> str: + """Check the status and recent output of a background command. + + Args: + command_id: The ID returned by start_command. + + Returns: + Status and recent output of the background command. + """ + bg = self._background.get(command_id) + if bg is None: + return f'[Error: unknown command ID {command_id!r}]' + + if not bg.finished and bg.proc.returncode is not None: + bg.exit_code = bg.proc.returncode + bg.finished = True + + stdout, stderr = self._read_bg_output(bg) + + status = 'finished' if bg.finished else 'running' + parts = [f'[status: {status}]'] + if bg.finished and bg.exit_code is not None: + parts.append(f'[exit code: {bg.exit_code}]') + if stdout: + parts.append(f'[stdout]\n{self._truncate(stdout)}') + if stderr: + parts.append(f'[stderr]\n{self._truncate(stderr)}') + if not stdout and not stderr: + parts.append('(no output yet)') + + return '\n'.join(parts) + + async def stop_command(self, command_id: str) -> str: + """Stop a background command and return its final output. + + Args: + command_id: The ID returned by start_command. + + Returns: + Final output and exit status of the stopped command. + """ + bg = self._background.get(command_id) + if bg is None: + return f'[Error: unknown command ID {command_id!r}]' + + if not bg.finished: + await self._kill_process_group(bg.proc) + with anyio.CancelScope(shield=True): + await bg.proc.wait() + bg.exit_code = bg.proc.returncode + bg.finished = True + + stdout, stderr = self._read_bg_output(bg) + + self._cleanup_bg_files(bg) + del self._background[command_id] + await bg.proc.aclose() + + parts = [f'[stopped: {bg.command!r}]'] + if bg.exit_code is not None: + parts.append(f'[exit code: {bg.exit_code}]') + if stdout: + parts.append(f'[stdout]\n{self._truncate(stdout)}') + if stderr: + parts.append(f'[stderr]\n{self._truncate(stderr)}') + if not stdout and not stderr: + parts.append('(no output)') + + return '\n'.join(parts) diff --git a/pyproject.toml b/pyproject.toml index bbc43f0..fa9e34b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dev = [ 'logfire[httpx]>=4.31.0', "dirty-equals>=0.9.0", "inline-snapshot>=0.32.5", + "mutmut>=3.5.0", ] lint = [ 'ruff>=0.14', @@ -121,6 +122,9 @@ filterwarnings = [ 'error', # DBOS's run_sync triggers this on Python 3.12+ — not our code. 'ignore:There is no current event loop:DeprecationWarning', + # anyio subprocess cleanup can trigger these on some Python versions during GC. + 'ignore::pytest.PytestUnraisableExceptionWarning', + 'ignore::ResourceWarning', ] anyio_mode = 'auto' @@ -140,3 +144,18 @@ exclude_lines = [ 'assert_never', 'if TYPE_CHECKING:', ] + +[tool.mutmut] +paths_to_mutate = [ + 'pydantic_ai_harness/filesystem/_toolset.py', + 'pydantic_ai_harness/shell/_toolset.py', +] +tests_dir = ['tests/filesystem/', 'tests/shell/'] +also_copy = ['pydantic_ai_harness/', 'tests/'] +# Skip trio-parametrized tests during mutation testing — trio segfaults in +# mutmut's subprocess environment on Python 3.14 (not a code bug). +pytest_add_cli_args = ['-k', 'not trio'] +# Required on Python 3.14 / macOS — mutmut's subprocess workers segfault +# without debug mode. +debug = true +# See docs/mutation-testing.md for full results (89.7% kill rate, 60 equivalent mutants). diff --git a/tests/filesystem/__init__.py b/tests/filesystem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/filesystem/test_filesystem.py b/tests/filesystem/test_filesystem.py new file mode 100644 index 0000000..75bb56b --- /dev/null +++ b/tests/filesystem/test_filesystem.py @@ -0,0 +1,930 @@ +"""Exhaustive tests for the FileSystem capability and FileSystemToolset. + +Covers: +- Path traversal prevention (relative .., absolute, symlink escapes) +- Allow/deny/protected pattern enforcement +- All tool operations (read, write, edit, list, search, find, mkdir, info) +- Binary file detection +- Optimistic concurrency (hash-based conflict detection) +- Edge cases (empty files, encoding, large files, hidden files) +- Agent-level integration via TestModel +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic_ai import Agent +from pydantic_ai.models.test import TestModel + +from pydantic_ai_harness.filesystem import FileSystem +from pydantic_ai_harness.filesystem._toolset import FileSystemToolset, _content_hash, _format_lines, _is_binary + +# ============================================================================ +# Unit tests for helper functions +# ============================================================================ + + +class TestFormatLines: + def test_basic_formatting(self) -> None: + text = 'line1\nline2\nline3\n' + result = _format_lines(text, 0, 10) + assert ' 1\tline1\n' in result + assert ' 2\tline2\n' in result + assert ' 3\tline3\n' in result + + def test_offset(self) -> None: + text = 'a\nb\nc\nd\ne\n' + result = _format_lines(text, 2, 2) + assert ' 3\tc\n' in result + assert ' 4\td\n' in result + assert '... (1 more lines. Use offset=4 to continue reading.)' in result + + def test_offset_exceeds_length(self) -> None: + text = 'a\nb\n' + with pytest.raises(ValueError, match='Offset 5 exceeds file length'): + _format_lines(text, 5, 10) + + def test_empty_file(self) -> None: + result = _format_lines('', 0, 10) + assert result == '(empty file)\n' + + def test_no_trailing_newline(self) -> None: + text = 'no newline' + result = _format_lines(text, 0, 10) + assert result.endswith('\n') + + def test_continuation_hint(self) -> None: + text = '\n'.join(f'line{i}' for i in range(10)) + result = _format_lines(text, 0, 3) + assert '... (7 more lines. Use offset=3 to continue reading.)' in result + + +class TestIsBinary: + def test_text_content(self) -> None: + assert _is_binary(b'hello world\n') is False + + def test_binary_content(self) -> None: + assert _is_binary(b'hello\x00world') is True + + def test_null_after_sample(self) -> None: + data = b'x' * 9000 + b'\x00' + assert _is_binary(data) is False + + def test_null_at_boundary(self) -> None: + data = b'x' * 8191 + b'\x00' + assert _is_binary(data) is True + + def test_empty(self) -> None: + assert _is_binary(b'') is False + + +class TestContentHash: + def test_deterministic(self) -> None: + assert _content_hash('hello') == _content_hash('hello') + + def test_different_content(self) -> None: + assert _content_hash('hello') != _content_hash('world') + + def test_length(self) -> None: + assert len(_content_hash('test')) == 12 + + +# ============================================================================ +# FileSystemToolset tests +# ============================================================================ + + +@pytest.fixture +def fs_root(tmp_path: Path) -> Path: + """Create a temporary directory with test files.""" + (tmp_path / 'hello.txt').write_text('Hello, world!\n') + (tmp_path / 'multi.txt').write_text('line1\nline2\nline3\nline4\nline5\n') + (tmp_path / 'subdir').mkdir() + (tmp_path / 'subdir' / 'nested.py').write_text('print("nested")\n') + (tmp_path / '.hidden').write_text('secret\n') + (tmp_path / 'binary.bin').write_bytes(b'\x00\x01\x02\x03') + (tmp_path / '.git').mkdir() + (tmp_path / '.git' / 'config').write_text('[core]\n') + (tmp_path / '.env').write_text('SECRET_KEY=abc123\n') + return tmp_path + + +@pytest.fixture +def toolset(fs_root: Path) -> FileSystemToolset: + """Create a FileSystemToolset for the test root.""" + return FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=['.git/*', '.env', '.env.*'], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + + +class TestPathSecurity: + async def test_traversal_with_dotdot(self, toolset: FileSystemToolset) -> None: + with pytest.raises(PermissionError, match='resolves outside'): + toolset._resolve_path('../../../etc/passwd') + + async def test_traversal_absolute_path(self, toolset: FileSystemToolset) -> None: + with pytest.raises(PermissionError, match='resolves outside'): + toolset._resolve_path('/etc/passwd') + + async def test_traversal_encoded(self, toolset: FileSystemToolset) -> None: + with pytest.raises(PermissionError, match='resolves outside'): + toolset._resolve_path('subdir/../../..') + + async def test_symlink_escape(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Symlink pointing outside root is rejected.""" + target = Path('/tmp/symlink-escape-target') + target.write_text('escaped!\n') + try: + link = fs_root / 'escape_link' + link.symlink_to(target) + with pytest.raises(PermissionError, match='resolves outside'): + toolset._resolve_path('escape_link') + finally: + target.unlink(missing_ok=True) + + async def test_valid_path_resolves(self, toolset: FileSystemToolset, fs_root: Path) -> None: + result = toolset._resolve_path('hello.txt') + assert result == (fs_root / 'hello.txt').resolve() + + async def test_nested_path_resolves(self, toolset: FileSystemToolset) -> None: + result = toolset._resolve_path('subdir/nested.py') + assert result.name == 'nested.py' + + +class TestAccessPatterns: + async def test_denied_pattern_blocks(self, fs_root: Path) -> None: + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=['*.secret'], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + with pytest.raises(PermissionError, match='denied by pattern'): + ts._check_access('data.secret') + + async def test_denied_pattern_passes_non_matching(self, fs_root: Path) -> None: + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=['*.secret'], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + # Path that doesn't match any denied pattern should pass + ts._check_access('data.txt') + + async def test_allowed_pattern_permits(self, fs_root: Path) -> None: + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=['*.py'], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + # Should not raise for .py files + ts._check_access('test.py') + + async def test_allowed_pattern_blocks_non_matching(self, fs_root: Path) -> None: + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=['*.py'], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + with pytest.raises(PermissionError, match='does not match any allowed'): + ts._check_access('data.txt') + + async def test_protected_pattern_blocks_write(self, toolset: FileSystemToolset) -> None: + with pytest.raises(PermissionError, match='protected'): + toolset._check_access('.git/config', write=True) + + async def test_protected_pattern_allows_read(self, toolset: FileSystemToolset) -> None: + # Should not raise for read + toolset._check_access('.git/config', write=False) + + async def test_env_file_protected(self, toolset: FileSystemToolset) -> None: + with pytest.raises(PermissionError, match='protected'): + toolset._check_access('.env', write=True) + + async def test_write_non_protected_with_patterns_configured(self, toolset: FileSystemToolset) -> None: + # write=True on a path that doesn't match any protected pattern should pass + toolset._check_access('hello.txt', write=True) + + async def test_access_with_no_denied_patterns(self, fs_root: Path) -> None: + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + # No denied, no protected, no allowed → should pass for any path + ts._check_access('anything.txt', write=True) + + +class TestReadFile: + async def test_read_basic(self, toolset: FileSystemToolset) -> None: + result = await toolset.read_file('hello.txt') + assert 'Hello, world!' in result + assert 'hash:' in result + assert '1 lines' in result + + async def test_read_with_offset(self, toolset: FileSystemToolset) -> None: + result = await toolset.read_file('multi.txt', offset=2) + assert 'line3' in result + assert 'line1' not in result + + async def test_read_with_limit(self, toolset: FileSystemToolset) -> None: + result = await toolset.read_file('multi.txt', limit=2) + assert 'line1' in result + assert 'line2' in result + assert '... (3 more lines' in result + + async def test_read_directory_raises(self, toolset: FileSystemToolset) -> None: + with pytest.raises(FileNotFoundError, match='is a directory'): + await toolset.read_file('subdir') + + async def test_read_missing_raises(self, toolset: FileSystemToolset) -> None: + with pytest.raises(FileNotFoundError, match='File not found'): + await toolset.read_file('nonexistent.txt') + + async def test_read_binary_file(self, toolset: FileSystemToolset) -> None: + result = await toolset.read_file('binary.bin') + assert 'Binary file' in result + assert '4 bytes' in result + + async def test_read_traversal_blocked(self, toolset: FileSystemToolset) -> None: + with pytest.raises(PermissionError): + await toolset.read_file('../../../etc/passwd') + + +class TestWriteFile: + async def test_write_new_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: + result = await toolset.write_file('new.txt', 'new content\n') + assert 'Wrote' in result + assert (fs_root / 'new.txt').read_text() == 'new content\n' + + async def test_write_creates_parents(self, toolset: FileSystemToolset, fs_root: Path) -> None: + result = await toolset.write_file('deep/nested/file.txt', 'deep\n') + assert 'Wrote' in result + assert (fs_root / 'deep' / 'nested' / 'file.txt').read_text() == 'deep\n' + + async def test_write_overwrite(self, toolset: FileSystemToolset, fs_root: Path) -> None: + await toolset.write_file('hello.txt', 'overwritten\n') + assert (fs_root / 'hello.txt').read_text() == 'overwritten\n' + + async def test_write_conflict_detection(self, toolset: FileSystemToolset, fs_root: Path) -> None: + # Get current hash + content = (fs_root / 'hello.txt').read_text() + current_hash = _content_hash(content) + + # Write with correct hash succeeds + await toolset.write_file('hello.txt', 'updated\n', expected_hash=current_hash) + assert (fs_root / 'hello.txt').read_text() == 'updated\n' + + async def test_write_conflict_rejection(self, toolset: FileSystemToolset, fs_root: Path) -> None: + with pytest.raises(ValueError, match='Conflict'): + await toolset.write_file('hello.txt', 'bad\n', expected_hash='wrong_hash_x') + + async def test_write_protected_blocked(self, toolset: FileSystemToolset) -> None: + with pytest.raises(PermissionError, match='protected'): + await toolset.write_file('.env', 'HACKED=true\n') + + async def test_write_returns_hash(self, toolset: FileSystemToolset) -> None: + result = await toolset.write_file('hashed.txt', 'content\n') + assert 'hash:' in result + + +class TestEditFile: + async def test_edit_basic(self, toolset: FileSystemToolset, fs_root: Path) -> None: + result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Hello, universe!') + assert 'Edited' in result + assert (fs_root / 'hello.txt').read_text() == 'Hello, universe!\n' + + async def test_edit_not_found_text(self, toolset: FileSystemToolset) -> None: + with pytest.raises(ValueError, match='old_text not found'): + await toolset.edit_file('hello.txt', 'NONEXISTENT', 'replacement') + + async def test_edit_ambiguous_match(self, toolset: FileSystemToolset, fs_root: Path) -> None: + (fs_root / 'repeat.txt').write_text('foo bar foo\n') + with pytest.raises(ValueError, match='found 2 times'): + await toolset.edit_file('repeat.txt', 'foo', 'baz') + + async def test_edit_missing_file(self, toolset: FileSystemToolset) -> None: + with pytest.raises(FileNotFoundError, match='File not found'): + await toolset.edit_file('ghost.txt', 'x', 'y') + + async def test_edit_conflict_detection(self, toolset: FileSystemToolset, fs_root: Path) -> None: + content = (fs_root / 'hello.txt').read_text() + current_hash = _content_hash(content) + result = await toolset.edit_file('hello.txt', 'Hello', 'Hi', expected_hash=current_hash) + assert 'hash:' in result + + async def test_edit_conflict_rejection(self, toolset: FileSystemToolset) -> None: + with pytest.raises(ValueError, match='Conflict'): + await toolset.edit_file('hello.txt', 'Hello', 'Hi', expected_hash='stale_hash_') + + async def test_edit_protected_blocked(self, toolset: FileSystemToolset) -> None: + with pytest.raises(PermissionError, match='protected'): + await toolset.edit_file('.env', 'SECRET', 'HACKED') + + async def test_edit_returns_new_hash(self, toolset: FileSystemToolset) -> None: + result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Goodbye!') + assert 'hash:' in result + + +class TestListDirectory: + async def test_list_root(self, toolset: FileSystemToolset) -> None: + result = await toolset.list_directory('.') + assert 'hello.txt' in result + assert 'subdir/' in result + + async def test_list_subdir(self, toolset: FileSystemToolset) -> None: + result = await toolset.list_directory('subdir') + assert 'nested.py' in result + + async def test_list_not_a_dir(self, toolset: FileSystemToolset) -> None: + with pytest.raises(NotADirectoryError): + await toolset.list_directory('hello.txt') + + async def test_list_shows_sizes(self, toolset: FileSystemToolset) -> None: + result = await toolset.list_directory('.') + assert 'bytes' in result + + async def test_list_shows_dir_indicator(self, toolset: FileSystemToolset) -> None: + result = await toolset.list_directory('.') + assert 'subdir/' in result + + async def test_list_empty_directory(self, toolset: FileSystemToolset, fs_root: Path) -> None: + (fs_root / 'empty').mkdir() + result = await toolset.list_directory('empty') + assert result == '(empty directory)' + + +class TestSearchFiles: + async def test_search_basic(self, toolset: FileSystemToolset) -> None: + result = await toolset.search_files('Hello') + assert 'hello.txt:1:Hello, world!' in result + + async def test_search_regex(self, toolset: FileSystemToolset) -> None: + result = await toolset.search_files(r'line\d') + assert 'multi.txt' in result + + async def test_search_no_matches(self, toolset: FileSystemToolset) -> None: + result = await toolset.search_files('ZZZZNOTHERE') + assert result == 'No matches found.' + + async def test_search_skips_hidden(self, toolset: FileSystemToolset) -> None: + result = await toolset.search_files('secret') + assert '.hidden' not in result + + async def test_search_skips_binary(self, toolset: FileSystemToolset) -> None: + result = await toolset.search_files('.') + assert 'binary.bin' not in result + + async def test_search_invalid_regex(self, toolset: FileSystemToolset) -> None: + with pytest.raises(ValueError, match='Invalid regex'): + await toolset.search_files('[invalid') + + async def test_search_include_glob(self, toolset: FileSystemToolset) -> None: + result = await toolset.search_files('print', include_glob='*.py') + assert 'nested.py' in result + + async def test_search_include_glob_excludes(self, toolset: FileSystemToolset) -> None: + result = await toolset.search_files('Hello', include_glob='*.py') + assert result == 'No matches found.' + + async def test_search_in_specific_file(self, toolset: FileSystemToolset) -> None: + result = await toolset.search_files('line', path='multi.txt') + assert 'multi.txt' in result + + async def test_search_truncation(self, fs_root: Path) -> None: + # Create many matching files + for i in range(20): + (fs_root / f'match{i}.txt').write_text('findme\n' * 100) + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=50, + max_find_results=1000, + ) + result = await ts.search_files('findme') + assert 'truncated at 50 matches' in result + + +class TestFindFiles: + async def test_find_glob(self, toolset: FileSystemToolset) -> None: + result = await toolset.find_files('*.txt') + assert 'hello.txt' in result + assert 'multi.txt' in result + + async def test_find_recursive(self, toolset: FileSystemToolset) -> None: + result = await toolset.find_files('**/*.py') + assert 'nested.py' in result + + async def test_find_no_matches(self, toolset: FileSystemToolset) -> None: + result = await toolset.find_files('*.xyz') + assert result == 'No matches found.' + + async def test_find_skips_hidden(self, toolset: FileSystemToolset) -> None: + result = await toolset.find_files('*') + assert '.hidden' not in result + assert '.git' not in result + + async def test_find_not_a_dir(self, toolset: FileSystemToolset) -> None: + with pytest.raises(NotADirectoryError): + await toolset.find_files('*.txt', path='hello.txt') + + async def test_find_in_subdir(self, toolset: FileSystemToolset) -> None: + result = await toolset.find_files('*.py', path='subdir') + assert 'nested.py' in result + + async def test_find_directories(self, toolset: FileSystemToolset) -> None: + result = await toolset.find_files('sub*') + assert 'subdir/' in result + + async def test_find_truncation(self, fs_root: Path) -> None: + for i in range(20): + (fs_root / f'file{i}.dat').write_text(f'{i}\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=5, + ) + result = await ts.find_files('*.dat') + assert 'truncated at 5 matches' in result + + +class TestCreateDirectory: + async def test_create_basic(self, toolset: FileSystemToolset, fs_root: Path) -> None: + result = await toolset.create_directory('newdir') + assert 'Created directory' in result + assert (fs_root / 'newdir').is_dir() + + async def test_create_nested(self, toolset: FileSystemToolset, fs_root: Path) -> None: + await toolset.create_directory('a/b/c') + assert (fs_root / 'a' / 'b' / 'c').is_dir() + + async def test_create_existing_ok(self, toolset: FileSystemToolset) -> None: + result = await toolset.create_directory('subdir') + assert 'Created directory' in result + + async def test_create_protected_blocked(self, toolset: FileSystemToolset) -> None: + with pytest.raises(PermissionError, match='protected'): + await toolset.create_directory('.git/hooks') + + +class TestFileInfo: + async def test_info_file(self, toolset: FileSystemToolset) -> None: + result = await toolset.file_info('hello.txt') + assert 'type: file' in result + assert 'size:' in result + assert 'lines:' in result + assert 'hash:' in result + assert 'binary: False' in result + + async def test_info_directory(self, toolset: FileSystemToolset) -> None: + result = await toolset.file_info('subdir') + assert 'type: directory' in result + + async def test_info_binary(self, toolset: FileSystemToolset) -> None: + result = await toolset.file_info('binary.bin') + assert 'binary: True' in result + assert 'lines:' not in result + + async def test_info_not_found(self, toolset: FileSystemToolset) -> None: + with pytest.raises(FileNotFoundError, match='Path not found'): + await toolset.file_info('nonexistent') + + async def test_info_symlink(self, toolset: FileSystemToolset, fs_root: Path) -> None: + link = fs_root / 'link.txt' + link.symlink_to(fs_root / 'hello.txt') + result = await toolset.file_info('link.txt') + assert 'type: file' in result + assert 'symlink_target:' in result + + +# ============================================================================ +# Capability integration tests +# ============================================================================ + + +# ============================================================================ +# Mutation-killing tests (boundary conditions, operator swaps, negation) +# ============================================================================ + + +class TestMutationKillers: + """Tests targeting specific mutations that might survive.""" + + async def test_format_lines_offset_equals_total(self) -> None: + """Kill: offset >= total → offset > total.""" + text = 'a\nb\n' # 2 lines + with pytest.raises(ValueError, match='Offset 2 exceeds file length'): + _format_lines(text, 2, 10) + + async def test_format_lines_exact_fit_no_continuation(self) -> None: + """Kill: remaining > 0 → remaining >= 0.""" + text = 'a\nb\nc\n' # 3 lines + result = _format_lines(text, 0, 3) + assert '... (' not in result + assert 'more lines' not in result + + async def test_format_lines_exact_fit_from_offset(self) -> None: + """Kill: remaining > 0 → remaining >= 0 with offset.""" + text = 'a\nb\nc\n' # 3 lines + result = _format_lines(text, 1, 2) # lines 2-3, 0 remaining + assert '... (' not in result + assert 'more lines' not in result + + async def test_format_lines_one_line_remaining(self) -> None: + """Kill: remaining > 0 → remaining > 1.""" + text = 'a\nb\nc\n' # 3 lines + result = _format_lines(text, 0, 2) + assert '... (1 more lines. Use offset=2 to continue reading.)' in result + + async def test_format_lines_line_number_starts_at_one(self) -> None: + """Kill: start=offset + 1 → start=offset.""" + text = 'first\nsecond\n' + result = _format_lines(text, 0, 10) + assert ' 1\tfirst\n' in result + assert ' 0\t' not in result + + async def test_format_lines_offset_line_numbering(self) -> None: + """Kill: start=offset + 1 → start=offset + 2.""" + text = 'a\nb\nc\n' + result = _format_lines(text, 1, 2) + assert ' 2\tb\n' in result + assert ' 3\tc\n' in result + + async def test_is_binary_exactly_at_sample_boundary(self) -> None: + """Kill: sample_size mutations at the exact boundary.""" + # Null byte at position 8191 (index 8191, within first 8192 bytes) + data = b'x' * 8191 + b'\x00' + assert _is_binary(data) is True + # Null byte at position 8192 (outside the sample) + data2 = b'x' * 8192 + b'\x00' + assert _is_binary(data2) is False + + async def test_content_hash_returns_exactly_12_chars(self) -> None: + """Kill: [:12] → [:11] or [:13].""" + h = _content_hash('test content') + assert len(h) == 12 + # Verify it's hex characters + assert all(c in '0123456789abcdef' for c in h) + + async def test_write_file_with_hash_on_new_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: expected_hash is not None and resolved.is_file() → expected_hash is not None. + + When a file doesn't exist, expected_hash should be ignored and the write should succeed. + """ + result = await toolset.write_file('brand_new.txt', 'new content\n', expected_hash='any_hash_val') + assert 'Wrote' in result + assert (fs_root / 'brand_new.txt').read_text() == 'new content\n' + + async def test_edit_file_single_match_succeeds(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: count > 1 → count >= 1 (single match must not raise).""" + (fs_root / 'unique.txt').write_text('unique text here\n') + result = await toolset.edit_file('unique.txt', 'unique text', 'replaced text') + assert 'Edited' in result + assert (fs_root / 'unique.txt').read_text() == 'replaced text here\n' + + async def test_edit_file_zero_matches_raises(self, toolset: FileSystemToolset) -> None: + """Kill: count == 0 → count != 0 or count == 1.""" + with pytest.raises(ValueError, match='old_text not found'): + await toolset.edit_file('hello.txt', 'DEFINITELY NOT IN FILE', 'x') + + async def test_search_truncation_stops_after_limit(self, fs_root: Path) -> None: + """Kill: removing the 'break' after truncation message.""" + # Create many files with 1 match each so truncation is per-file + for i in range(10): + (fs_root / f'searchable{i}.txt').write_text(f'match_this_{i}\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=5, + max_find_results=1000, + ) + result = await ts.search_files('match_this') + lines = result.strip().split('\n') + # Truncation check is after each file, so 5 matches + truncation msg + # Ensure we don't get all 10 matches + match_lines = [ln for ln in lines if ln.startswith('searchable')] + assert len(match_lines) <= 5 + assert 'truncated at 5 matches' in lines[-1] + + async def test_find_truncation_stops_after_limit(self, fs_root: Path) -> None: + """Kill: removing the 'break' after truncation in find_files.""" + for i in range(10): + (fs_root / f'findme{i:02d}.dat').write_text(f'{i}\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=3, + ) + result = await ts.find_files('*.dat') + lines = result.strip().split('\n') + # Should have exactly 4 lines: 3 matches + 1 truncation message + assert len(lines) == 4 + assert 'truncated at 3 matches' in lines[-1] + + async def test_read_file_default_limit_used(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: if limit is None: limit = self._max_read_lines → removing this.""" + # Create file with more lines than we'd see with limit=0 + (fs_root / 'big.txt').write_text('\n'.join(f'line{i}' for i in range(100)) + '\n') + result = await toolset.read_file('big.txt') + # All 100 lines should be present since max_read_lines is 2000 + assert 'line99' in result + + async def test_list_directory_with_files_not_empty(self, toolset: FileSystemToolset) -> None: + """Kill: 'entries' being falsy check — ensure non-empty dirs return actual content.""" + result = await toolset.list_directory('subdir') + assert result != '(empty directory)' + assert 'nested.py' in result + + async def test_search_in_file_returns_only_that_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: if resolved.is_file(): files = [resolved] → files = sorted(resolved.rglob('*')).""" + # Both files contain 'Hello' / 'hello' but searching a specific file should only return from that file + (fs_root / 'other.txt').write_text('Hello from other\n') + result = await toolset.search_files('Hello', path='hello.txt') + assert 'hello.txt' in result + assert 'other.txt' not in result + + async def test_file_info_non_binary_shows_lines_and_hash(self, toolset: FileSystemToolset) -> None: + """Kill: not is_bin → is_bin (negation of binary check in file_info).""" + result = await toolset.file_info('hello.txt') + assert 'lines: 1' in result + assert 'hash:' in result + assert 'binary: False' in result + + async def test_file_info_binary_no_lines_no_hash(self, toolset: FileSystemToolset) -> None: + """Kill: not is_bin → is_bin (ensure binary files DON'T get lines/hash).""" + result = await toolset.file_info('binary.bin') + assert 'binary: True' in result + assert 'lines:' not in result + assert 'hash:' not in result + + async def test_safe_resolve_passes_write_flag(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: _safe_resolve not passing write= to _check_access.""" + # Protected patterns block writes but allow reads + (fs_root / '.env.local').write_text('SECRET=x\n') + # Read should work (write=False internally) + result = await toolset.read_file('.env.local') + assert 'SECRET=x' in result + # Write should be blocked (write=True internally) + with pytest.raises(PermissionError, match='protected'): + await toolset.write_file('.env.local', 'HACKED\n') + + async def test_format_lines_join_separator(self) -> None: + """Kill: ''.join(numbered) → 'XXXX'.join(numbered). + + Verify the result doesn't contain garbage between lines. + """ + text = 'a\nb\nc\n' + result = _format_lines(text, 0, 3) + # Lines should be directly adjacent (no separator between them) + assert ' 1\ta\n 2\tb\n 3\tc\n' in result + + async def test_format_lines_no_trailing_newline_preserves_content(self) -> None: + """Kill: result += '\\n' → result = '\\n' (content destroyed).""" + text = 'no newline' + result = _format_lines(text, 0, 10) + # The content must still be present + assert 'no newline' in result + assert result.endswith('\n') + + async def test_read_file_hash_is_real_hash(self, toolset: FileSystemToolset) -> None: + """Kill: content_hash = _content_hash(text) → content_hash = None.""" + result = await toolset.read_file('hello.txt') + # The actual hash should be a hex string, not 'None' + assert 'hash:None' not in result + # Verify the hash matches what we'd compute + expected_hash = _content_hash('Hello, world!\n') + assert f'hash:{expected_hash}' in result + + async def test_read_file_non_ascii_content(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: errors='replace' removal and errors='XXreplaceXX'. + + With invalid UTF-8 bytes, the tool should not crash — it should use replacement chars. + """ + # Write raw bytes that are invalid UTF-8 + (fs_root / 'broken_utf8.txt').write_bytes(b'hello \xff\xfe world\n') + result = await toolset.read_file('broken_utf8.txt') + # Should not crash, content should contain replacement characters + assert 'hello' in result + assert 'world' in result + + async def test_read_file_default_offset_starts_at_first_line(self, toolset: FileSystemToolset) -> None: + """Kill: offset: int = 0 → offset: int = 1 (default param change). + + The first line must be included when no offset is specified. + """ + result = await toolset.read_file('multi.txt') + # First line must be present (line1) + assert ' 1\tline1' in result + # Verify line numbering starts at 1 + assert ' 0\t' not in result + + async def test_toolset_tool_names(self, toolset: FileSystemToolset) -> None: + """Kill: name='read_file' → name=None / name='XXread_fileXX'. + + Verify tools are registered with correct names. + """ + tool_names = set(toolset.tools.keys()) + assert 'read_file' in tool_names + assert 'write_file' in tool_names + assert 'edit_file' in tool_names + assert 'list_directory' in tool_names + assert 'search_files' in tool_names + assert 'find_files' in tool_names + assert 'create_directory' in tool_names + assert 'file_info' in tool_names + + async def test_write_file_output_format(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: write return string mutations.""" + result = await toolset.write_file('fmt.txt', 'ab\ncd\n') + # Verify specific format: chars, lines, path, hash + assert 'Wrote 6 chars (2 lines) to fmt.txt.' in result + assert 'hash:' in result + # Verify hash is a real hex hash not None + assert 'hash:None' not in result + + async def test_edit_file_output_format(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: edit return string mutations.""" + result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Hi') + assert result.startswith('Edited hello.txt.') + assert 'hash:' in result + assert 'hash:None' not in result + + def test_format_lines_no_double_trailing_newline(self) -> None: + """Kill: result.endswith('\\n') → result.endswith('XX\\nXX'). + + Text that already ends with newline must NOT get a second one appended. + """ + text = 'hello\n' + result = _format_lines(text, 0, 10) + # Exact match: no trailing double newline + assert result == ' 1\thello\n' + + def test_safe_resolve_write_default_is_false(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: _safe_resolve write: bool = False → True. + + Synchronous test to avoid trio crash confusing mutmut. + Protected files should be READABLE via _safe_resolve's default (write=False). + """ + (fs_root / '.env.local').write_text('SECRET=x\n') + # _safe_resolve without write= uses default write=False → read is allowed + resolved = toolset._safe_resolve('.env.local') + assert resolved.name == '.env.local' + # But with write=True, it should raise + with pytest.raises(PermissionError, match='protected'): + toolset._safe_resolve('.env.local', write=True) + + async def test_list_directory_exact_size(self, toolset: FileSystemToolset) -> None: + """Kill: size = stat.st_size → size = None.""" + result = await toolset.list_directory('.') + # hello.txt has 'Hello, world!\n' = 14 bytes + assert '14 bytes' in result + + async def test_list_directory_no_garbage_separator(self, toolset: FileSystemToolset) -> None: + """Kill: '\\n'.join(entries) → 'XX\\nXX'.join(entries).""" + result = await toolset.list_directory('.') + assert 'XX' not in result + + async def test_list_directory_error_message(self, toolset: FileSystemToolset) -> None: + """Kill: NotADirectoryError(f'...') → NotADirectoryError(None).""" + with pytest.raises(NotADirectoryError, match='Not a directory'): + await toolset.list_directory('hello.txt') + + async def test_find_files_error_message(self, toolset: FileSystemToolset) -> None: + """Kill: NotADirectoryError(f'...') → NotADirectoryError(None).""" + with pytest.raises(NotADirectoryError, match='Not a directory'): + await toolset.find_files('*.txt', path='hello.txt') + + async def test_find_files_no_suffix_on_files(self, toolset: FileSystemToolset) -> None: + """Kill: suffix '' → 'XXXX' for non-directory entries.""" + result = await toolset.find_files('*.txt') + for line in result.splitlines(): + if not line.endswith('/'): + assert 'XXXX' not in line + + async def test_find_files_no_garbage_separator(self, toolset: FileSystemToolset) -> None: + """Kill: '\\n'.join(matches) → 'XX\\nXX'.join(matches).""" + result = await toolset.find_files('*.txt') + assert 'XX' not in result + + async def test_search_files_no_garbage_separator(self, toolset: FileSystemToolset) -> None: + """Kill: '\\n'.join(results) → 'XX\\nXX'.join(results).""" + result = await toolset.search_files(r'line\d') + assert 'XX' not in result + + async def test_file_info_exact_size(self, toolset: FileSystemToolset) -> None: + """Kill: size = stat.st_size → size = None.""" + result = await toolset.file_info('hello.txt') + assert '14 bytes' in result + + async def test_file_info_no_garbage_separator(self, toolset: FileSystemToolset) -> None: + """Kill: '\\n'.join(parts) → 'XX\\nXX'.join(parts).""" + result = await toolset.file_info('hello.txt') + assert 'XX' not in result + + async def test_search_with_invalid_utf8_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: + """Kill: errors='replace' removal and errors='XXreplaceXX'. + + A file with invalid UTF-8 (but no null bytes = not binary) should be searchable. + """ + # Write a file with invalid UTF-8 but no null bytes (not detected as binary) + (fs_root / 'bad_encoding.txt').write_bytes(b'marker_text \xff\xfe end\n') + result = await toolset.search_files('marker_text') + # Should find the file even with broken encoding + assert 'bad_encoding.txt' in result + + async def test_search_binary_skip_does_not_stop_iteration(self, toolset: FileSystemToolset) -> None: + """Kill: if _is_binary(raw): continue → break. + + A binary file must be skipped, but subsequent text files must still be searched. + """ + # binary.bin exists in the fixture and comes before 'hello.txt' alphabetically + result = await toolset.search_files('Hello') + # hello.txt must still be found (binary.bin didn't break the loop) + assert 'hello.txt' in result + + async def test_find_hidden_skip_does_not_stop_iteration(self, toolset: FileSystemToolset) -> None: + """Kill: if any(part.startswith('.')): continue → break. + + Hidden files must be skipped, but subsequent visible files must still appear. + """ + # .hidden comes before hello.txt alphabetically — skipping must not break the loop + result = await toolset.find_files('*') + assert 'hello.txt' in result + assert 'multi.txt' in result + + +class TestFileSystemCapability: + def test_default_construction(self) -> None: + fs = FileSystem() + assert fs.root_dir == '.' + assert fs.max_read_lines == 2000 + + def test_custom_construction(self, tmp_path: Path) -> None: + fs = FileSystem( + root_dir=tmp_path, + allowed_patterns=['*.py'], + denied_patterns=['test_*'], + max_read_lines=500, + ) + assert fs.max_read_lines == 500 + + def test_get_toolset_returns_toolset(self, tmp_path: Path) -> None: + fs = FileSystem(root_dir=tmp_path) + toolset = fs.get_toolset() + assert isinstance(toolset, FileSystemToolset) + + def test_protected_defaults(self) -> None: + fs = FileSystem() + assert '.git/*' in fs.protected_patterns + assert '.env' in fs.protected_patterns + + @pytest.mark.anyio(backends=['asyncio']) + async def test_agent_integration(self, tmp_path: Path, anyio_backend: object) -> None: + if str(anyio_backend) != 'asyncio': + pytest.skip('Agent.run requires asyncio event loop') + (tmp_path / 'test.txt').write_text('hello agent\n') + model = TestModel(custom_output_text='done', call_tools=[]) + agent: Agent[None, str] = Agent(model, capabilities=[FileSystem(root_dir=tmp_path)]) + result = await agent.run('read test.txt') + assert result.output == 'done' diff --git a/tests/shell/__init__.py b/tests/shell/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shell/test_shell.py b/tests/shell/test_shell.py new file mode 100644 index 0000000..d8eb644 --- /dev/null +++ b/tests/shell/test_shell.py @@ -0,0 +1,1086 @@ +"""Tests for the Shell capability and ShellToolset.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from pydantic_ai import Agent +from pydantic_ai.models.test import TestModel + +from pydantic_ai_harness.shell import Shell +from pydantic_ai_harness.shell._toolset import ( + _PWD_SENTINEL, + ShellToolset, + _is_interactive_command, +) + +# ============================================================================ +# ============================================================================ + + +class TestIsInteractiveCommand: + def test_vi(self) -> None: + assert _is_interactive_command('vi file.txt') is True + + def test_vim(self) -> None: + assert _is_interactive_command('vim file.txt') is True + + def test_nano(self) -> None: + assert _is_interactive_command('nano file.txt') is True + + def test_less(self) -> None: + assert _is_interactive_command('less file.txt') is True + + def test_top(self) -> None: + assert _is_interactive_command('top') is True + + def test_sudo(self) -> None: + assert _is_interactive_command('sudo rm -rf /') is True + + def test_ssh(self) -> None: + assert _is_interactive_command('ssh host') is True + + def test_regular_command(self) -> None: + assert _is_interactive_command('ls -la') is False + + def test_echo(self) -> None: + assert _is_interactive_command('echo hello') is False + + def test_grep(self) -> None: + assert _is_interactive_command('grep pattern file') is False + + def test_emacs(self) -> None: + assert _is_interactive_command('emacs file.txt') is True + + def test_man(self) -> None: + assert _is_interactive_command('man ls') is True + + def test_htop(self) -> None: + assert _is_interactive_command('htop') is True + + def test_telnet(self) -> None: + assert _is_interactive_command('telnet localhost 80') is True + + def test_ftp(self) -> None: + assert _is_interactive_command('ftp host') is True + + def test_passwd(self) -> None: + assert _is_interactive_command('passwd') is True + + def test_more(self) -> None: + assert _is_interactive_command('more file.txt') is True + + def test_not_prefix_match(self) -> None: + assert _is_interactive_command('view file.txt') is False + assert _is_interactive_command('vishnu') is False + + def test_leading_spaces(self) -> None: + assert _is_interactive_command(' vi file.txt') is True + assert _is_interactive_command(' sudo rm') is True + + +# ============================================================================ +# ============================================================================ + + +@pytest.fixture +def shell_dir(tmp_path: Path) -> Path: + """Create a temporary directory for shell tests.""" + (tmp_path / 'test.txt').write_text('hello\n') + (tmp_path / 'subdir').mkdir() + (tmp_path / 'subdir' / 'nested.txt').write_text('nested\n') + return tmp_path + + +@pytest.fixture +def toolset(shell_dir: Path) -> ShellToolset: + """Create a basic ShellToolset.""" + return ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=['rm', 'rmdir'], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + + +class TestCommandValidation: + async def test_denied_command_blocked(self, toolset: ShellToolset) -> None: + with pytest.raises(PermissionError, match="'rm' is denied"): + toolset._check_command('rm -rf /') + + async def test_allowed_command_permitted(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=['echo', 'cat'], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + ts._check_command('echo hello') + ts._check_command('cat file.txt') + + async def test_allowed_blocks_non_matching(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=['echo'], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + with pytest.raises(PermissionError, match='not in the allowed list'): + ts._check_command('cat file.txt') + + async def test_both_allow_and_deny_raises(self, shell_dir: Path) -> None: + with pytest.raises(ValueError, match='Specify allowed_commands or denied_commands'): + ShellToolset( + cwd=shell_dir, + allowed_commands=['echo'], + denied_commands=['rm'], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + + async def test_interactive_blocked_by_default(self, toolset: ShellToolset) -> None: + with pytest.raises(PermissionError, match='Interactive commands'): + toolset._check_command('vim file.txt') + + async def test_interactive_allowed_when_enabled(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=True, + ) + ts._check_command('vim file.txt') + + async def test_denied_operator_blocked(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=['>', '>>'], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + with pytest.raises(PermissionError, match="'>' is not allowed"): + ts._check_command('echo hello > file.txt') + + async def test_denied_operator_passes_when_not_present(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=['>', '>>'], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + ts._check_command('echo hello') + + async def test_unparseable_command_allowed(self, toolset: ShellToolset) -> None: + toolset._check_command("echo 'unterminated") + + async def test_empty_command_allowed(self, toolset: ShellToolset) -> None: + toolset._check_command('') + + async def test_denied_operator_substring_match(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=['>>'], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + with pytest.raises(PermissionError, match="'>>' is not allowed"): + ts._check_command('echo hello >> file.txt') + + async def test_shlex_error_returns_early(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=['rm'], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + ts._check_command("echo 'unterminated") + + async def test_empty_tokens(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=['echo'], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + ts._check_command('') + + +class TestTruncation: + def test_within_limit(self, toolset: ShellToolset) -> None: + assert toolset._truncate('short') == 'short' + + def test_at_limit(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=10, + persist_cwd=False, + allow_interactive=False, + ) + result = ts._truncate('x' * 10) + assert result == 'x' * 10 + + def test_over_limit(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=10, + persist_cwd=False, + allow_interactive=False, + ) + result = ts._truncate('x' * 20) + assert result.startswith('x' * 10) + assert 'truncated at 10 chars' in result + + def test_exactly_at_limit_not_truncated(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=10, + persist_cwd=False, + allow_interactive=False, + ) + result = ts._truncate('x' * 10) + assert result == 'x' * 10 + assert 'truncated' not in result + + def test_one_over_limit_truncated(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=10, + persist_cwd=False, + allow_interactive=False, + ) + result = ts._truncate('x' * 11) + assert result.startswith('x' * 10) + assert 'truncated at 10 chars' in result + + def test_smart_truncation_with_stderr(self, shell_dir: Path) -> None: + """When stderr_text is provided and output is over limit, use smart truncation.""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=100, + persist_cwd=False, + allow_interactive=False, + ) + long_text = 'x' * 200 + result = ts._truncate(long_text, stderr_text='error msg') + assert 'stdout truncated' in result + assert len(result) < 200 + + def test_smart_truncation_not_triggered_under_limit(self, shell_dir: Path) -> None: + """When under limit, stderr_text parameter is irrelevant.""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=100, + persist_cwd=False, + allow_interactive=False, + ) + result = ts._truncate('short', stderr_text='error') + assert result == 'short' + + def test_truncation_without_stderr_uses_basic(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=10, + persist_cwd=False, + allow_interactive=False, + ) + result = ts._truncate('x' * 20) + assert 'output truncated at 10 chars' in result + assert 'stdout truncated' not in result + + def test_truncation_with_stderr_uses_smart(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=10, + persist_cwd=False, + allow_interactive=False, + ) + result = ts._truncate('x' * 20, stderr_text='err') + assert 'stdout truncated' in result + assert 'output truncated' not in result + + +class TestCwdSentinel: + def test_wrap_command_appends_sentinel(self, toolset: ShellToolset) -> None: + result = toolset._wrap_command_for_cwd('echo hello') + assert _PWD_SENTINEL in result + assert result == f'echo hello && echo {_PWD_SENTINEL}$(pwd)' + + def test_extract_cwd_no_sentinel(self, toolset: ShellToolset) -> None: + cleaned, cwd = toolset._extract_cwd_from_output('just some output') + assert cleaned == 'just some output' + assert cwd is None + + def test_extract_cwd_with_valid_path(self, toolset: ShellToolset, shell_dir: Path) -> None: + stdout = f'some output\n{_PWD_SENTINEL}{shell_dir}\n' + cleaned, cwd = toolset._extract_cwd_from_output(stdout) + assert 'some output' in cleaned + assert _PWD_SENTINEL not in cleaned + assert cwd == shell_dir + + def test_extract_cwd_invalid_path(self, toolset: ShellToolset) -> None: + stdout = f'output\n{_PWD_SENTINEL}/nonexistent_dir_xyz_999\n' + cleaned, cwd = toolset._extract_cwd_from_output(stdout) + assert _PWD_SENTINEL not in cleaned + assert cwd is None + + def test_extract_cwd_empty_path(self, toolset: ShellToolset) -> None: + stdout = f'output\n{_PWD_SENTINEL}\n' + _, cwd = toolset._extract_cwd_from_output(stdout) + assert cwd is None + + def test_extract_cwd_strips_sentinel_from_output(self, toolset: ShellToolset, shell_dir: Path) -> None: + """Sentinel line should never appear in output shown to model.""" + stdout = f'line1\nline2\n{_PWD_SENTINEL}{shell_dir}\n' + cleaned, _ = toolset._extract_cwd_from_output(stdout) + assert _PWD_SENTINEL not in cleaned + assert 'line1' in cleaned + assert 'line2' in cleaned + + def test_extract_cwd_uses_rfind(self, toolset: ShellToolset, shell_dir: Path) -> None: + """If sentinel appears multiple times, use the LAST one (rfind).""" + stdout = f'{_PWD_SENTINEL}/fake\nmore output\n{_PWD_SENTINEL}{shell_dir}\n' + _, cwd = toolset._extract_cwd_from_output(stdout) + assert cwd == shell_dir + + def test_extract_cwd_cleaned_rstrip(self, toolset: ShellToolset, shell_dir: Path) -> None: + stdout = f'content\n\n{_PWD_SENTINEL}{shell_dir}\n' + cleaned, _ = toolset._extract_cwd_from_output(stdout) + assert not cleaned.endswith('\n') + assert 'content' in cleaned + + def test_extract_cwd_split_maxsplit(self, toolset: ShellToolset, shell_dir: Path) -> None: + stdout = f'{_PWD_SENTINEL}{shell_dir}\nextra_line\n' + _, cwd = toolset._extract_cwd_from_output(stdout) + assert cwd == shell_dir + + +class TestRunCommand: + async def test_basic_echo(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('echo hello') + assert '[stdout]' in result + assert 'hello' in result + + async def test_stderr_output(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('echo error >&2') + assert '[stderr]' in result + assert 'error' in result + + async def test_mixed_output(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('echo out && echo err >&2') + assert '[stdout]' in result + assert '[stderr]' in result + + async def test_exit_code_reported(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('exit 42') + assert '[exit code: 42]' in result + + async def test_exit_code_zero_not_shown(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('echo ok') + assert 'exit code' not in result + + async def test_timeout(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=0.5, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command('sleep 10') + assert 'timed out' in result + + async def test_custom_timeout(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('sleep 10', timeout_seconds=0.5) + assert 'timed out' in result + + async def test_no_output(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('true') + assert result == '(no output)' + + async def test_output_truncation(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command(f'{sys.executable} -c "print(\'x\' * 200)"') + assert 'truncated at 50 chars' in result + + async def test_persist_cwd(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=True, + allow_interactive=False, + ) + await ts.run_command('cd subdir') + result = await ts.run_command('pwd') + assert 'subdir' in result + + async def test_persist_cwd_only_on_success(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=True, + allow_interactive=False, + ) + original = ts._cwd + await ts.run_command('cd nonexistent_dir_xyz && false') + assert ts._cwd == original + + async def test_denied_command_in_run(self, toolset: ShellToolset) -> None: + with pytest.raises(PermissionError, match="'rm' is denied"): + await toolset.run_command('rm -rf /') + + async def test_cwd_used(self, toolset: ShellToolset, shell_dir: Path) -> None: + result = await toolset.run_command('cat test.txt') + assert 'hello' in result + + async def test_multiline_output(self, toolset: ShellToolset) -> None: + result = await toolset.run_command(f'{sys.executable} -c "print(\'a\\nb\\nc\\n\')"') + assert '[stdout]' in result + + async def test_timeout_reports_value(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=0.5, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command('sleep 10') + assert 'timed out after 0.5s' in result + + async def test_custom_timeout_overrides_default(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=30.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command('sleep 10', timeout_seconds=0.5) + assert 'timed out after 0.5s' in result + + async def test_persist_cwd_disabled_no_update(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + original = ts._cwd + await ts.run_command('cd subdir') + assert ts._cwd == original + + async def test_nonzero_exit_shows_code(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('exit 1') + assert '[exit code: 1]' in result + + async def test_zero_exit_no_code(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('echo success') + assert 'exit code' not in result + + async def test_stdout_stderr_separated_by_newline(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('echo out && echo err >&2') + assert '[stdout]\nout\n\n[stderr]\nerr' in result + + async def test_non_ascii_stdout(self, toolset: ShellToolset) -> None: + result = await toolset.run_command( + f'{sys.executable} -c "import sys; sys.stdout.buffer.write(b\'hello \\xff\\xfe world\\n\')"' + ) + assert 'hello' in result + + async def test_non_ascii_stderr(self, toolset: ShellToolset) -> None: + result = await toolset.run_command( + f'{sys.executable} -c "import sys; sys.stderr.buffer.write(b\'err \\xff\\xfe msg\\n\')"' + ) + assert 'err' in result + + async def test_stdout_chunk_join(self, toolset: ShellToolset) -> None: + result = await toolset.run_command(f"{sys.executable} -c \"print('A' * 100 + 'B' * 100)\"") + assert 'A' * 100 + 'B' * 100 in result + + async def test_exact_no_output_message(self, toolset: ShellToolset) -> None: + result = await toolset.run_command('true') + assert result == '(no output)' + + async def test_exit_code_fallback_to_zero(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=True, + allow_interactive=False, + ) + result = await ts.run_command('echo ok') + assert 'exit code' not in result + + async def test_error_message_content(self, shell_dir: Path) -> None: + with pytest.raises(ValueError, match='^Specify allowed_commands or denied_commands, not both\\.$'): + ShellToolset( + cwd=shell_dir, + allowed_commands=['echo'], + denied_commands=['rm'], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + + async def test_stdout_chunks_joined_cleanly(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=30.0, + max_output_chars=500_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command("printf '%05000d\\n' $(seq 1 100)") + assert 'XXXX' not in result + + async def test_stderr_chunks_joined_cleanly(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=30.0, + max_output_chars=500_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command("printf '%0500d\\n' $(seq 1 100) >&2") + assert 'XXXX' not in result + + async def test_persist_cwd_sentinel_stripped_from_output(self, shell_dir: Path) -> None: + """The pwd sentinel should never appear in output shown to user.""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=True, + allow_interactive=False, + ) + result = await ts.run_command('echo visible') + assert _PWD_SENTINEL not in result + assert 'visible' in result + + async def test_persist_cwd_updates_after_cd(self, shell_dir: Path) -> None: + """CWD should update to the actual directory after a successful cd.""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=True, + allow_interactive=False, + ) + await ts.run_command('cd subdir') + assert ts._cwd == (shell_dir / 'subdir') + + async def test_persist_cwd_not_updated_on_failure(self, shell_dir: Path) -> None: + """CWD should not update if command fails (exit code non-zero).""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=True, + allow_interactive=False, + ) + original = ts._cwd + await ts.run_command('false') + assert ts._cwd == original + + +class TestProcessGroupKill: + async def test_timeout_kills_subprocess_tree(self, shell_dir: Path) -> None: + """On timeout, the entire process group should be killed.""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=0.5, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command('bash -c "sleep 100 & sleep 100"') + assert 'timed out' in result + + async def test_timeout_with_output_before_timeout(self, shell_dir: Path) -> None: + """Output produced before timeout should still result in timeout message.""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=0.5, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command('echo before_timeout && sleep 100') + assert 'timed out' in result + + async def test_start_new_session_used(self, shell_dir: Path) -> None: + """Verify the process gets its own session (child is process group leader).""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command(f'{sys.executable} -c "import os; print(os.getpgid(0) == os.getpid())"') + assert 'True' in result + + +class TestBackgroundCommands: + async def test_start_command_returns_id(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.start_command('sleep 100') + assert 'ID:' in result + assert 'Started background command' in result + command_id = result.split('ID: ')[1].strip() + await ts.stop_command(command_id) + + async def test_check_unknown_id(self, toolset: ShellToolset) -> None: + result = await toolset.check_command('nonexistent_id') + assert 'unknown command ID' in result + + async def test_stop_unknown_id(self, toolset: ShellToolset) -> None: + result = await toolset.stop_command('nonexistent_id') + assert 'unknown command ID' in result + + async def test_start_and_stop(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + start_result = await ts.start_command('echo hello_bg') + command_id = start_result.split('ID: ')[1].strip() + + import anyio + + await anyio.sleep(0.5) + + stop_result = await ts.stop_command(command_id) + assert 'stopped' in stop_result + assert 'hello_bg' in stop_result + + async def test_start_and_check_running(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + start_result = await ts.start_command('sleep 100') + command_id = start_result.split('ID: ')[1].strip() + + check_result = await ts.check_command(command_id) + assert 'running' in check_result + + await ts.stop_command(command_id) + + async def test_start_and_check_finished(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + start_result = await ts.start_command('echo done_quick') + command_id = start_result.split('ID: ')[1].strip() + + import anyio + + await anyio.sleep(0.5) + + check_result = await ts.check_command(command_id) + assert 'finished' in check_result + assert 'done_quick' in check_result + + await ts.stop_command(command_id) + + async def test_start_denied_command_raises(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=['rm'], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + with pytest.raises(PermissionError, match="'rm' is denied"): + await ts.start_command('rm -rf /') + + async def test_stop_captures_stderr(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + start_result = await ts.start_command('echo err_bg >&2') + command_id = start_result.split('ID: ')[1].strip() + + import anyio + + await anyio.sleep(0.5) + + stop_result = await ts.stop_command(command_id) + assert 'err_bg' in stop_result + + async def test_stop_no_output(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + start_result = await ts.start_command('true') + command_id = start_result.split('ID: ')[1].strip() + + import anyio + + await anyio.sleep(0.5) + + stop_result = await ts.stop_command(command_id) + assert '(no output)' in stop_result + + async def test_check_no_output_yet(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + start_result = await ts.start_command('sleep 100') + command_id = start_result.split('ID: ')[1].strip() + + check_result = await ts.check_command(command_id) + assert 'no output yet' in check_result + + await ts.stop_command(command_id) + + async def test_start_command_uses_cwd(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + start_result = await ts.start_command('pwd') + command_id = start_result.split('ID: ')[1].strip() + + import anyio + + await anyio.sleep(0.5) + + stop_result = await ts.stop_command(command_id) + assert str(shell_dir) in stop_result + + async def test_stop_removes_from_registry(self, shell_dir: Path) -> None: + """After stop, the command_id should no longer be known.""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + start_result = await ts.start_command('true') + command_id = start_result.split('ID: ')[1].strip() + + import anyio + + await anyio.sleep(0.5) + + await ts.stop_command(command_id) + + # Should now be unknown + check_result = await ts.check_command(command_id) + assert 'unknown command ID' in check_result + + +# ============================================================================ +# ============================================================================ + + +class TestEdgeCases: + async def test_toolset_tool_names(self, toolset: ShellToolset) -> None: + tool_names = list(toolset.tools.keys()) + assert 'run_command' in tool_names + assert 'start_command' in tool_names + assert 'check_command' in tool_names + assert 'stop_command' in tool_names + + async def test_run_command_uses_actual_cwd(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command('pwd') + assert str(shell_dir) in result + + def test_wrap_command_uses_correct_sentinel(self, toolset: ShellToolset) -> None: + result = toolset._wrap_command_for_cwd('ls') + assert '__HARNESS_PWD__' in result + assert '$(pwd)' in result + + def test_extract_cwd_rfind_not_find(self, toolset: ShellToolset, shell_dir: Path) -> None: + stdout = f'{_PWD_SENTINEL}/fake\nstuff\n{_PWD_SENTINEL}{shell_dir}\n' + _, cwd = toolset._extract_cwd_from_output(stdout) + assert cwd == shell_dir + + async def test_persist_cwd_requires_all_three_conditions(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=True, + allow_interactive=False, + ) + # Successful echo — sentinel shows same dir, cwd should remain valid + await ts.run_command('echo hi') + assert ts._cwd.is_dir() + + async def test_persist_cwd_false_skips_sentinel(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command('echo test') + assert _PWD_SENTINEL not in result + + async def test_start_new_session_true(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.run_command(f'{sys.executable} -c "import os; print(os.getpgid(0) == os.getpid())"') + assert 'True' in result + + +# ============================================================================ +# ============================================================================ + + +class TestShellCapability: + def test_default_construction(self) -> None: + shell = Shell() + assert shell.cwd == '.' + assert shell.default_timeout == 30.0 + assert 'rm' in shell.denied_commands + + def test_custom_construction(self) -> None: + shell = Shell( + cwd='/tmp', + allowed_commands=['echo', 'cat'], + denied_commands=[], + default_timeout=60.0, + ) + assert shell.default_timeout == 60.0 + + def test_get_toolset_returns_toolset(self, tmp_path: Path) -> None: + shell = Shell(cwd=tmp_path) + toolset = shell.get_toolset() + assert isinstance(toolset, ShellToolset) + + def test_default_denied_commands(self) -> None: + shell = Shell() + assert 'rm' in shell.denied_commands + assert 'dd' in shell.denied_commands + assert 'shutdown' in shell.denied_commands + + @pytest.mark.anyio(backends=['asyncio']) + async def test_agent_integration(self, tmp_path: Path) -> None: + model = TestModel(custom_output_text='done', call_tools=[]) + agent: Agent[None, str] = Agent(model, capabilities=[Shell(cwd=tmp_path)]) + result = await agent.run('run echo hello') + assert result.output == 'done' diff --git a/uv.lock b/uv.lock index 27b3bf5..877a7a4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] [options] @@ -560,6 +565,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/2c/4b209e9dd6700cea0c0e39d7e5e70e9f494f817a374174a823bd11561d31/inline_snapshot-0.33.0-py3-none-any.whl", hash = "sha256:76b8c2c5899d27d3d464d1160eb3b8eee179ba635bb80a8e5e93220f10b60207", size = 89625, upload-time = "2026-05-12T18:39:46.43Z" }, ] +[[package]] +name = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/52/97d5454dee9d014821fe0c88f3dc0e83131b97dd074a4d49537056a75475/libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9", size = 2211698, upload-time = "2025-11-03T22:31:50.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a4/d1205985d378164687af3247a9c8f8bdb96278b0686ac98ab951bc6d336a/libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6", size = 2093104, upload-time = "2025-11-03T22:31:52.189Z" }, + { url = "https://files.pythonhosted.org/packages/9e/de/1338da681b7625b51e584922576d54f1b8db8fc7ff4dc79121afc5d4d2cd/libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58", size = 2237419, upload-time = "2025-11-03T22:31:53.526Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/ee66f2d83b870534756e593d464d8b33b0914c224dff3a407e0f74dc04e0/libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8", size = 2300820, upload-time = "2025-11-03T22:31:55.995Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ca/959088729de8e0eac8dd516e4fb8623d8d92bad539060fa85c9e94d418a5/libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba", size = 2301201, upload-time = "2025-11-03T22:31:57.347Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4c/2a21a8c452436097dfe1da277f738c3517f3f728713f16d84b9a3d67ca8d/libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b", size = 2408213, upload-time = "2025-11-03T22:31:59.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/26/8f7b671fad38a515bb20b038718fd2221ab658299119ac9bcec56c2ced27/libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073", size = 2119189, upload-time = "2025-11-03T22:32:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5b/bf/ffb23a48e27001165cc5c81c5d9b3d6583b21b7f5449109e03a0020b060c/libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100", size = 2001736, upload-time = "2025-11-03T22:32:02.986Z" }, + { url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289, upload-time = "2025-11-03T22:32:04.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927, upload-time = "2025-11-03T22:32:06.209Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002, upload-time = "2025-11-03T22:32:07.559Z" }, + { url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048, upload-time = "2025-11-03T22:32:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675, upload-time = "2025-11-03T22:32:10.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934, upload-time = "2025-11-03T22:32:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247, upload-time = "2025-11-03T22:32:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774, upload-time = "2025-11-03T22:32:14.647Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "logfire" version = "4.33.0" @@ -605,6 +690,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -614,6 +716,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mutmut" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "coverage" }, + { name = "libcst" }, + { name = "pytest" }, + { name = "setproctitle" }, + { name = "textual" }, + { name = "toml", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/0d/9ce4fc8b219504a336eb814c5a7ea8e379ad93ce05327ff3842aea93bf0b/mutmut-3.5.0.tar.gz", hash = "sha256:548186d4b0c494b7b9895db82871cb1f229b9271c9ff7cd633e348dd9afcc772", size = 36389, upload-time = "2026-02-22T18:46:41.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/23/ac475f6db39643946feb09290a2178d603d2b623034d56d3f5059cddb769/mutmut-3.5.0-py3-none-any.whl", hash = "sha256:f19f2dd2e977eb9dc17255d8cb11e24fbfc3191620fba3108cac25779c9d78c9", size = 34242, upload-time = "2026-02-22T18:46:43.113Z" }, +] + [[package]] name = "nexus-rpc" version = "1.4.0" @@ -778,6 +898,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -934,6 +1063,7 @@ dev = [ { name = "dirty-equals" }, { name = "inline-snapshot" }, { name = "logfire", extra = ["httpx"] }, + { name = "mutmut" }, { name = "pydantic-ai-harness", extra = ["code-mode"] }, { name = "pytest" }, { name = "pytest-anyio" }, @@ -960,6 +1090,7 @@ dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "inline-snapshot", specifier = ">=0.32.5" }, { name = "logfire", extras = ["httpx"], specifier = ">=4.31.0" }, + { name = "mutmut", specifier = ">=3.5.0" }, { name = "pydantic-ai-harness", extras = ["code-mode"] }, { name = "pytest", specifier = ">=9.0.0" }, { name = "pytest-anyio" }, @@ -1327,6 +1458,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + [[package]] name = "requests" version = "2.34.0" @@ -1380,6 +1535,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/48/fb401ec8c4953d519d05c87feca816ad668b8258448ff60579ac7a1c1386/setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b", size = 18079, upload-time = "2025-09-05T12:49:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a3/c2b0333c2716fb3b4c9a973dd113366ac51b4f8d56b500f4f8f704b4817a/setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7", size = 13099, upload-time = "2025-09-05T12:49:09.222Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f8/17bda581c517678260e6541b600eeb67745f53596dc077174141ba2f6702/setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c", size = 31793, upload-time = "2025-09-05T12:49:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/27/d1/76a33ae80d4e788ecab9eb9b53db03e81cfc95367ec7e3fbf4989962fedd/setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3", size = 32779, upload-time = "2025-09-05T12:49:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/59/27/1a07c38121967061564f5e0884414a5ab11a783260450172d4fc68c15621/setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f", size = 34578, upload-time = "2025-09-05T12:49:13.393Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/725e6353935962d8bb12cbf7e7abba1d0d738c7f6935f90239d8e1ccf913/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64", size = 32030, upload-time = "2025-09-05T12:49:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/e4677ae8e1cb0d549ab558b12db10c175a889be0974c589c428fece5433e/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5", size = 33363, upload-time = "2025-09-05T12:49:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/55/d4/69ce66e4373a48fdbb37489f3ded476bb393e27f514968c3a69a67343ae0/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381", size = 31508, upload-time = "2025-09-05T12:49:18.032Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5a/42c1ed0e9665d068146a68326529b5686a1881c8b9197c2664db4baf6aeb/setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c", size = 12558, upload-time = "2025-09-05T12:49:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/dd206cc19a25561921456f6cb12b405635319299b6f366e0bebe872abc18/setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95", size = 13245, upload-time = "2025-09-05T12:49:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, + { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/34/8a/aff5506ce89bc3168cb492b18ba45573158d528184e8a9759a05a09088a9/setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6", size = 12654, upload-time = "2025-09-05T12:51:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/5b6f2faedd6ced3d3c085a5efbd91380fb1f61f4c12bc42acad37932f4e9/setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2", size = 14284, upload-time = "2025-09-05T12:51:18.393Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c0/4312fed3ca393a29589603fd48f17937b4ed0638b923bac75a728382e730/setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78", size = 13282, upload-time = "2025-09-05T12:51:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1496,6 +1735,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/e6/a147fad980d0f92b7b070d4fe439310b91375592c26d8cb6dc5d1a1c0ae4/temporalio-1.27.1-cp310-abi3-win_amd64.whl", hash = "sha256:a3afaed09643cfb24ac04837144ff37a02a0eac7eeeb1876065066806aeda512", size = 14979491, upload-time = "2026-05-13T16:20:50.622Z" }, ] +[[package]] +name = "textual" +version = "8.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1634,6 +1899,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From 592ba630eb326082b2a8da3fa1178a34fd6060f6 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Wed, 27 May 2026 18:50:47 -0500 Subject: [PATCH 02/11] Clean-up Filesystem and Shell capabilities --- docs/mutation-testing.md | 10 + pydantic_ai_harness/__init__.py | 6 +- pydantic_ai_harness/filesystem/_capability.py | 34 +- pydantic_ai_harness/filesystem/_toolset.py | 47 +- pydantic_ai_harness/shell/_capability.py | 26 +- pydantic_ai_harness/shell/_toolset.py | 61 ++- pyproject.toml | 5 +- tests/filesystem/test_filesystem.py | 139 ++---- tests/shell/test_shell.py | 463 +++++++++++++++--- tests/test_placeholder.py | 21 + 10 files changed, 531 insertions(+), 281 deletions(-) diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index 715cffd..1e356d3 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -1,5 +1,8 @@ # Mutation Testing Results +> Generated from commit `bd268c8` on 2026-05-26. Results may become stale as code +> evolves — regenerate via `uv run mutmut run --max-children 1`. + Covers `pydantic_ai_harness/filesystem/_toolset.py` and `pydantic_ai_harness/shell/_toolset.py`. Run with [mutmut](https://mutmut.readthedocs.io/) v3 (`uv run mutmut run --max-children 1`). @@ -29,6 +32,13 @@ All 60 survivors are provably equivalent — no test can distinguish them from t | Dead `returncode` branch | 1 | `proc.returncode` is never `None` after `await proc.wait()` | | `errors='replace'` mutations | 3 | Test data is valid UTF-8; the error handler is never invoked | +## Limitations + +Trio-parametrized tests are excluded during mutation testing (`-k 'not trio'` in +`pyproject.toml [tool.mutmut]`) because trio segfaults in mutmut's subprocess +environment on Python 3.14 / macOS. This does not affect the kill rate — trio +tests exercise the same code paths as the asyncio tests. + ## Running ```bash diff --git a/pydantic_ai_harness/__init__.py b/pydantic_ai_harness/__init__.py index 7fdb81c..4f6f62d 100644 --- a/pydantic_ai_harness/__init__.py +++ b/pydantic_ai_harness/__init__.py @@ -1,4 +1,4 @@ -"""The batteries for your Pydantic AI agent -- the official capability library.""" +"""Pydantic AI capability library.""" from typing import TYPE_CHECKING @@ -15,11 +15,11 @@ def __getattr__(name: str) -> object: from .code_mode import CodeMode return CodeMode - if name == 'FileSystem': + elif name == 'FileSystem': from .filesystem import FileSystem return FileSystem - if name == 'Shell': + elif name == 'Shell': from .shell import Shell return Shell diff --git a/pydantic_ai_harness/filesystem/_capability.py b/pydantic_ai_harness/filesystem/_capability.py index 28ab4ec..c45868b 100644 --- a/pydantic_ai_harness/filesystem/_capability.py +++ b/pydantic_ai_harness/filesystem/_capability.py @@ -24,35 +24,19 @@ @dataclass class FileSystem(AbstractCapability[Any]): - """Capability that provides file system access scoped to a root directory. + """File system access scoped to a root directory. - All paths supplied by the model are resolved relative to `root_dir`. - Traversal above the root is rejected. Symlinks are resolved before - authorization to prevent escape via symlink. - - Security features: - - Path traversal prevention (canonical path resolution) - - Symlink-aware containment checks - - Glob-based allow/deny filtering - - Protected path patterns (secrets, keys, .git by default) - - Binary file detection - - Optimistic concurrency via content hashing - - Example:: - - from pydantic_ai import Agent - from pydantic_ai_harness.filesystem import FileSystem - - agent = Agent('openai:gpt-4o', capabilities=[FileSystem(root_dir='.')]) + All paths are resolved relative to `root_dir`. Traversal above the root + is rejected. Symlinks are resolved before authorization. """ root_dir: str | Path = '.' """Root directory for all file operations. Defaults to the current directory.""" - allowed_patterns: Sequence[str] = field(default_factory=lambda: list[str]()) + allowed_patterns: Sequence[str] = field(default_factory=list[str]) """If non-empty, only paths matching at least one glob pattern are accessible.""" - denied_patterns: Sequence[str] = field(default_factory=lambda: list[str]()) + denied_patterns: Sequence[str] = field(default_factory=list[str]) """Paths matching any of these glob patterns are rejected.""" protected_patterns: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_PROTECTED)) @@ -71,7 +55,13 @@ class FileSystem(AbstractCapability[Any]): max_find_results: int = 1000 """Maximum number of matches returned by `find_files`.""" - def get_toolset(self) -> AgentToolset[Any] | None: + def __post_init__(self) -> None: + for name in ('max_read_lines', 'max_search_results', 'max_find_results'): + value = getattr(self, name) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f'{name} must be a positive integer, got {value!r}') + + def get_toolset(self) -> AgentToolset[Any]: """Build and return the filesystem toolset.""" return FileSystemToolset( root_dir=Path(self.root_dir), diff --git a/pydantic_ai_harness/filesystem/_toolset.py b/pydantic_ai_harness/filesystem/_toolset.py index 0caf0a0..51a4ee7 100644 --- a/pydantic_ai_harness/filesystem/_toolset.py +++ b/pydantic_ai_harness/filesystem/_toolset.py @@ -1,12 +1,4 @@ -"""Filesystem toolset implementation with security-first design. - -Incorporates best practices from: -- MCP filesystem server: root containment, symlink-aware path checks -- Codex CLI: policy-based access, protected paths, metadata preservation -- Aider: robust search/replace editing with conflict detection -- SWE-agent: configurable tool surface, binary detection -- CrewAI: centralized safe-path validators -""" +"""Filesystem toolset providing sandboxed file operations.""" from __future__ import annotations @@ -22,16 +14,7 @@ def _format_lines(text: str, offset: int, limit: int) -> str: - """Format text with line numbers. - - Args: - text: The raw file content. - offset: Zero-based line offset to start from. - limit: Maximum number of lines to include. - - Returns: - Numbered text with a continuation hint when more lines remain. - """ + """Format text with line numbers and continuation hint.""" lines = text.splitlines(keepends=True) total = len(lines) @@ -105,22 +88,13 @@ def __init__( self.add_function(self.create_directory, name='create_directory') self.add_function(self.file_info, name='file_info') - # ------------------------------------------------------------------ - # Path security - # ------------------------------------------------------------------ - def _resolve_path(self, path: str) -> Path: """Resolve path relative to root, rejecting traversal. Uses os.path.realpath for symlink resolution before checking containment. """ - # Normalize and join with root candidate = (self._root / path).resolve() - - # Symlink-aware: resolve realpath to catch symlink escapes real = Path(os.path.realpath(candidate)) - - # Containment check against real root real_root = Path(os.path.realpath(self._root)) if not real.is_relative_to(real_root): raise PermissionError(f'Path {path!r} resolves outside the root directory.') @@ -129,19 +103,16 @@ def _resolve_path(self, path: str) -> Path: def _check_access(self, path: str, *, write: bool = False) -> None: """Validate path against allow/deny/protected patterns.""" - # Check protected patterns (always denied for writes) if write and self._protected_patterns: matched = next((p for p in self._protected_patterns if fnmatch.fnmatch(path, p)), None) if matched: raise PermissionError(f'Path {path!r} is protected (matches {matched!r}).') - # Check deny patterns if self._denied_patterns: matched = next((p for p in self._denied_patterns if fnmatch.fnmatch(path, p)), None) if matched: raise PermissionError(f'Path {path!r} is denied by pattern {matched!r}.') - # Check allow patterns (if configured, path must match at least one) if self._allowed_patterns: if not any(fnmatch.fnmatch(path, p) for p in self._allowed_patterns): raise PermissionError(f'Path {path!r} does not match any allowed pattern.') @@ -151,10 +122,6 @@ def _safe_resolve(self, path: str, *, write: bool = False) -> Path: self._check_access(path, write=write) return self._resolve_path(path) - # ------------------------------------------------------------------ - # Tool implementations - # ------------------------------------------------------------------ - async def read_file(self, path: str, *, offset: int = 0, limit: int | None = None) -> str: """Read a text file with line numbers. @@ -321,10 +288,8 @@ async def search_files(self, pattern: str, *, path: str = '.', include_glob: str rel_parts = file_path.relative_to(real_root).parts except ValueError: # pragma: no cover continue - # Skip hidden files/directories if any(part.startswith('.') for part in rel_parts): continue - # Apply include_glob filter rel_str = str(file_path.relative_to(real_root)) if include_glob and not fnmatch.fnmatch(rel_str, include_glob): continue @@ -332,7 +297,6 @@ async def search_files(self, pattern: str, *, path: str = '.', include_glob: str raw = file_path.read_bytes() except OSError: # pragma: no cover continue - # Skip binary files if _is_binary(raw): continue text = raw.decode('utf-8', errors='replace') @@ -366,7 +330,6 @@ async def find_files(self, pattern: str, *, path: str = '.') -> str: rel_parts = match.relative_to(real_root).parts except ValueError: # pragma: no cover continue - # Skip hidden files/directories if any(part.startswith('.') for part in rel_parts): continue rel = str(match.relative_to(real_root)) @@ -419,9 +382,9 @@ async def file_info(self, path: str) -> str: is_bin = _is_binary(raw) parts.append(f'binary: {is_bin}') if not is_bin: - line_count = len(raw.decode('utf-8', errors='replace').splitlines()) - parts.append(f'lines: {line_count}') - parts.append(f'hash: {_content_hash(raw.decode("utf-8", errors="replace"))}') + text = raw.decode('utf-8', errors='replace') + parts.append(f'lines: {len(text.splitlines())}') + parts.append(f'hash: {_content_hash(text)}') if is_link: parts.append(f'symlink_target: {os.readlink(original)}') diff --git a/pydantic_ai_harness/shell/_capability.py b/pydantic_ai_harness/shell/_capability.py index 83fb08a..143c5b9 100644 --- a/pydantic_ai_harness/shell/_capability.py +++ b/pydantic_ai_harness/shell/_capability.py @@ -25,35 +25,19 @@ 'init', ] -_DEFAULT_DENIED_OPERATORS: list[str] = [] - @dataclass class Shell(AbstractCapability[Any]): - """Gives an agent the ability to run shell commands. - - Commands execute in a subprocess rooted at ``cwd``. Use ``allowed_commands`` - or ``denied_commands`` to control what the agent can invoke. Output is - automatically truncated to keep model context manageable. - - Example:: + """Shell command execution for agents. - from pydantic_ai import Agent - from pydantic_ai_harness.shell import Shell - - agent = Agent('openai:gpt-4o', capabilities=[Shell(cwd='.')]) - - # Only allow specific commands - agent = Agent( - 'openai:gpt-4o', - capabilities=[Shell(allowed_commands=['ls', 'cat', 'grep', 'find'])] - ) + Commands execute in a subprocess rooted at `cwd`. Use `allowed_commands` + or `denied_commands` to control what the agent can invoke. """ cwd: str | Path = '.' """Working directory for command execution.""" - allowed_commands: Sequence[str] = field(default_factory=lambda: list[str]()) + allowed_commands: Sequence[str] = field(default_factory=list[str]) """If non-empty, only these command names may be executed (allowlist).""" denied_commands: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_DENIED_COMMANDS)) @@ -63,7 +47,7 @@ class Shell(AbstractCapability[Any]): Set to an empty list to disable. """ - denied_operators: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_DENIED_OPERATORS)) + denied_operators: Sequence[str] = field(default_factory=list[str]) """Shell operators that are blocked (e.g. '>', '>>', '|' for restrictive mode).""" default_timeout: float = 30.0 diff --git a/pydantic_ai_harness/shell/_toolset.py b/pydantic_ai_harness/shell/_toolset.py index 1440753..81808af 100644 --- a/pydantic_ai_harness/shell/_toolset.py +++ b/pydantic_ai_harness/shell/_toolset.py @@ -96,6 +96,17 @@ def __init__( self.add_function(self.check_command, name='check_command') self.add_function(self.stop_command, name='stop_command') + async def __aexit__(self, *args: Any) -> None: + """Terminate all remaining background processes and clean up temp files.""" + for bg in self._background.values(): + if not bg.finished: + await self._kill_process_group(bg.proc) + with anyio.CancelScope(shield=True): + await bg.proc.wait() + await bg.proc.aclose() + self._cleanup_bg_files(bg) + self._background.clear() + def _check_command(self, command: str) -> None: """Validate command against allow/deny lists.""" if not self._allow_interactive and _is_interactive_command(command): @@ -131,7 +142,13 @@ def _truncate(self, text: str, *, stderr_text: str = '') -> str: return truncated def _wrap_command_for_cwd(self, command: str) -> str: - """Append pwd sentinel to command for cwd tracking.""" + """Append pwd sentinel to command for cwd tracking. + + Commands containing ';' are returned unwrapped because the separator + breaks the '&&' success-gating of the sentinel echo. + """ + if ';' in command: + return command return f'{command} && echo {_PWD_SENTINEL}$(pwd)' def _extract_cwd_from_output(self, stdout: str) -> tuple[str, Path | None]: @@ -280,6 +297,9 @@ async def _read_stderr() -> None: async def start_command(self, command: str) -> str: """Start a long-running command in the background (e.g. a server or watcher). + Callers MUST call `stop_command(command_id)` when done to terminate the + process and clean up temporary output files. + Args: command: The shell command to run in the background. @@ -292,13 +312,20 @@ async def start_command(self, command: str) -> str: stdout_file = tempfile.NamedTemporaryFile(mode='w+b', prefix=f'harness_{command_id}_out_', delete=False) stderr_file = tempfile.NamedTemporaryFile(mode='w+b', prefix=f'harness_{command_id}_err_', delete=False) - proc = await anyio.open_process( - command, - cwd=self._cwd, - stdout=stdout_file, - stderr=stderr_file, - start_new_session=True, - ) + try: + proc = await anyio.open_process( + command, + cwd=self._cwd, + stdout=stdout_file, + stderr=stderr_file, + start_new_session=True, + ) + except BaseException: + stdout_file.close() + stderr_file.close() + os.unlink(stdout_file.name) + os.unlink(stderr_file.name) + raise stdout_file.close() stderr_file.close() @@ -359,11 +386,14 @@ async def check_command(self, command_id: str) -> str: parts = [f'[status: {status}]'] if bg.finished and bg.exit_code is not None: parts.append(f'[exit code: {bg.exit_code}]') + output_sections: list[str] = [] if stdout: - parts.append(f'[stdout]\n{self._truncate(stdout)}') + output_sections.append(f'[stdout]\n{stdout}') if stderr: - parts.append(f'[stderr]\n{self._truncate(stderr)}') - if not stdout and not stderr: + output_sections.append(f'[stderr]\n{stderr}') + if output_sections: + parts.append(self._truncate('\n'.join(output_sections))) + else: parts.append('(no output yet)') return '\n'.join(parts) @@ -397,11 +427,14 @@ async def stop_command(self, command_id: str) -> str: parts = [f'[stopped: {bg.command!r}]'] if bg.exit_code is not None: parts.append(f'[exit code: {bg.exit_code}]') + output_sections: list[str] = [] if stdout: - parts.append(f'[stdout]\n{self._truncate(stdout)}') + output_sections.append(f'[stdout]\n{stdout}') if stderr: - parts.append(f'[stderr]\n{self._truncate(stderr)}') - if not stdout and not stderr: + output_sections.append(f'[stderr]\n{stderr}') + if output_sections: + parts.append(self._truncate('\n'.join(output_sections))) + else: parts.append('(no output)') return '\n'.join(parts) diff --git a/pyproject.toml b/pyproject.toml index fa9e34b..405fd7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,7 +110,7 @@ quote-style = 'single' [tool.pyright] pythonVersion = '3.10' typeCheckingMode = 'strict' -exclude = ['template', '.venv'] +exclude = ['template', '.venv', 'mutants'] executionEnvironments = [ { root = 'tests', reportPrivateUsage = false }, ] @@ -122,9 +122,6 @@ filterwarnings = [ 'error', # DBOS's run_sync triggers this on Python 3.12+ — not our code. 'ignore:There is no current event loop:DeprecationWarning', - # anyio subprocess cleanup can trigger these on some Python versions during GC. - 'ignore::pytest.PytestUnraisableExceptionWarning', - 'ignore::ResourceWarning', ] anyio_mode = 'auto' diff --git a/tests/filesystem/test_filesystem.py b/tests/filesystem/test_filesystem.py index 75bb56b..bc03e0d 100644 --- a/tests/filesystem/test_filesystem.py +++ b/tests/filesystem/test_filesystem.py @@ -1,14 +1,4 @@ -"""Exhaustive tests for the FileSystem capability and FileSystemToolset. - -Covers: -- Path traversal prevention (relative .., absolute, symlink escapes) -- Allow/deny/protected pattern enforcement -- All tool operations (read, write, edit, list, search, find, mkdir, info) -- Binary file detection -- Optimistic concurrency (hash-based conflict detection) -- Edge cases (empty files, encoding, large files, hidden files) -- Agent-level integration via TestModel -""" +"""Tests for the FileSystem capability and FileSystemToolset.""" from __future__ import annotations @@ -21,10 +11,6 @@ from pydantic_ai_harness.filesystem import FileSystem from pydantic_ai_harness.filesystem._toolset import FileSystemToolset, _content_hash, _format_lines, _is_binary -# ============================================================================ -# Unit tests for helper functions -# ============================================================================ - class TestFormatLines: def test_basic_formatting(self) -> None: @@ -91,14 +77,8 @@ def test_length(self) -> None: assert len(_content_hash('test')) == 12 -# ============================================================================ -# FileSystemToolset tests -# ============================================================================ - - @pytest.fixture def fs_root(tmp_path: Path) -> Path: - """Create a temporary directory with test files.""" (tmp_path / 'hello.txt').write_text('Hello, world!\n') (tmp_path / 'multi.txt').write_text('line1\nline2\nline3\nline4\nline5\n') (tmp_path / 'subdir').mkdir() @@ -113,7 +93,6 @@ def fs_root(tmp_path: Path) -> Path: @pytest.fixture def toolset(fs_root: Path) -> FileSystemToolset: - """Create a FileSystemToolset for the test root.""" return FileSystemToolset( root_dir=fs_root, allowed_patterns=[], @@ -140,7 +119,7 @@ async def test_traversal_encoded(self, toolset: FileSystemToolset) -> None: async def test_symlink_escape(self, toolset: FileSystemToolset, fs_root: Path) -> None: """Symlink pointing outside root is rejected.""" - target = Path('/tmp/symlink-escape-target') + target = fs_root.parent / 'symlink-escape-target' target.write_text('escaped!\n') try: link = fs_root / 'escape_link' @@ -531,61 +510,42 @@ async def test_info_symlink(self, toolset: FileSystemToolset, fs_root: Path) -> assert 'symlink_target:' in result -# ============================================================================ -# Capability integration tests -# ============================================================================ - - -# ============================================================================ -# Mutation-killing tests (boundary conditions, operator swaps, negation) -# ============================================================================ - - class TestMutationKillers: - """Tests targeting specific mutations that might survive.""" - async def test_format_lines_offset_equals_total(self) -> None: - """Kill: offset >= total → offset > total.""" text = 'a\nb\n' # 2 lines with pytest.raises(ValueError, match='Offset 2 exceeds file length'): _format_lines(text, 2, 10) async def test_format_lines_exact_fit_no_continuation(self) -> None: - """Kill: remaining > 0 → remaining >= 0.""" text = 'a\nb\nc\n' # 3 lines result = _format_lines(text, 0, 3) assert '... (' not in result assert 'more lines' not in result async def test_format_lines_exact_fit_from_offset(self) -> None: - """Kill: remaining > 0 → remaining >= 0 with offset.""" text = 'a\nb\nc\n' # 3 lines result = _format_lines(text, 1, 2) # lines 2-3, 0 remaining assert '... (' not in result assert 'more lines' not in result async def test_format_lines_one_line_remaining(self) -> None: - """Kill: remaining > 0 → remaining > 1.""" text = 'a\nb\nc\n' # 3 lines result = _format_lines(text, 0, 2) assert '... (1 more lines. Use offset=2 to continue reading.)' in result async def test_format_lines_line_number_starts_at_one(self) -> None: - """Kill: start=offset + 1 → start=offset.""" text = 'first\nsecond\n' result = _format_lines(text, 0, 10) assert ' 1\tfirst\n' in result assert ' 0\t' not in result async def test_format_lines_offset_line_numbering(self) -> None: - """Kill: start=offset + 1 → start=offset + 2.""" text = 'a\nb\nc\n' result = _format_lines(text, 1, 2) assert ' 2\tb\n' in result assert ' 3\tc\n' in result async def test_is_binary_exactly_at_sample_boundary(self) -> None: - """Kill: sample_size mutations at the exact boundary.""" # Null byte at position 8191 (index 8191, within first 8192 bytes) data = b'x' * 8191 + b'\x00' assert _is_binary(data) is True @@ -594,35 +554,28 @@ async def test_is_binary_exactly_at_sample_boundary(self) -> None: assert _is_binary(data2) is False async def test_content_hash_returns_exactly_12_chars(self) -> None: - """Kill: [:12] → [:11] or [:13].""" h = _content_hash('test content') assert len(h) == 12 # Verify it's hex characters assert all(c in '0123456789abcdef' for c in h) async def test_write_file_with_hash_on_new_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: expected_hash is not None and resolved.is_file() → expected_hash is not None. - - When a file doesn't exist, expected_hash should be ignored and the write should succeed. - """ + """When a file doesn't exist, expected_hash should be ignored and the write should succeed.""" result = await toolset.write_file('brand_new.txt', 'new content\n', expected_hash='any_hash_val') assert 'Wrote' in result assert (fs_root / 'brand_new.txt').read_text() == 'new content\n' async def test_edit_file_single_match_succeeds(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: count > 1 → count >= 1 (single match must not raise).""" (fs_root / 'unique.txt').write_text('unique text here\n') result = await toolset.edit_file('unique.txt', 'unique text', 'replaced text') assert 'Edited' in result assert (fs_root / 'unique.txt').read_text() == 'replaced text here\n' async def test_edit_file_zero_matches_raises(self, toolset: FileSystemToolset) -> None: - """Kill: count == 0 → count != 0 or count == 1.""" with pytest.raises(ValueError, match='old_text not found'): await toolset.edit_file('hello.txt', 'DEFINITELY NOT IN FILE', 'x') async def test_search_truncation_stops_after_limit(self, fs_root: Path) -> None: - """Kill: removing the 'break' after truncation message.""" # Create many files with 1 match each so truncation is per-file for i in range(10): (fs_root / f'searchable{i}.txt').write_text(f'match_this_{i}\n') @@ -644,7 +597,6 @@ async def test_search_truncation_stops_after_limit(self, fs_root: Path) -> None: assert 'truncated at 5 matches' in lines[-1] async def test_find_truncation_stops_after_limit(self, fs_root: Path) -> None: - """Kill: removing the 'break' after truncation in find_files.""" for i in range(10): (fs_root / f'findme{i:02d}.dat').write_text(f'{i}\n') ts = FileSystemToolset( @@ -663,7 +615,6 @@ async def test_find_truncation_stops_after_limit(self, fs_root: Path) -> None: assert 'truncated at 3 matches' in lines[-1] async def test_read_file_default_limit_used(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: if limit is None: limit = self._max_read_lines → removing this.""" # Create file with more lines than we'd see with limit=0 (fs_root / 'big.txt').write_text('\n'.join(f'line{i}' for i in range(100)) + '\n') result = await toolset.read_file('big.txt') @@ -671,13 +622,11 @@ async def test_read_file_default_limit_used(self, toolset: FileSystemToolset, fs assert 'line99' in result async def test_list_directory_with_files_not_empty(self, toolset: FileSystemToolset) -> None: - """Kill: 'entries' being falsy check — ensure non-empty dirs return actual content.""" result = await toolset.list_directory('subdir') assert result != '(empty directory)' assert 'nested.py' in result async def test_search_in_file_returns_only_that_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: if resolved.is_file(): files = [resolved] → files = sorted(resolved.rglob('*')).""" # Both files contain 'Hello' / 'hello' but searching a specific file should only return from that file (fs_root / 'other.txt').write_text('Hello from other\n') result = await toolset.search_files('Hello', path='hello.txt') @@ -685,21 +634,18 @@ async def test_search_in_file_returns_only_that_file(self, toolset: FileSystemTo assert 'other.txt' not in result async def test_file_info_non_binary_shows_lines_and_hash(self, toolset: FileSystemToolset) -> None: - """Kill: not is_bin → is_bin (negation of binary check in file_info).""" result = await toolset.file_info('hello.txt') assert 'lines: 1' in result assert 'hash:' in result assert 'binary: False' in result async def test_file_info_binary_no_lines_no_hash(self, toolset: FileSystemToolset) -> None: - """Kill: not is_bin → is_bin (ensure binary files DON'T get lines/hash).""" result = await toolset.file_info('binary.bin') assert 'binary: True' in result assert 'lines:' not in result assert 'hash:' not in result async def test_safe_resolve_passes_write_flag(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: _safe_resolve not passing write= to _check_access.""" # Protected patterns block writes but allow reads (fs_root / '.env.local').write_text('SECRET=x\n') # Read should work (write=False internally) @@ -710,17 +656,13 @@ async def test_safe_resolve_passes_write_flag(self, toolset: FileSystemToolset, await toolset.write_file('.env.local', 'HACKED\n') async def test_format_lines_join_separator(self) -> None: - """Kill: ''.join(numbered) → 'XXXX'.join(numbered). - - Verify the result doesn't contain garbage between lines. - """ + """Verify the result doesn't contain garbage between lines.""" text = 'a\nb\nc\n' result = _format_lines(text, 0, 3) # Lines should be directly adjacent (no separator between them) assert ' 1\ta\n 2\tb\n 3\tc\n' in result async def test_format_lines_no_trailing_newline_preserves_content(self) -> None: - """Kill: result += '\\n' → result = '\\n' (content destroyed).""" text = 'no newline' result = _format_lines(text, 0, 10) # The content must still be present @@ -728,7 +670,6 @@ async def test_format_lines_no_trailing_newline_preserves_content(self) -> None: assert result.endswith('\n') async def test_read_file_hash_is_real_hash(self, toolset: FileSystemToolset) -> None: - """Kill: content_hash = _content_hash(text) → content_hash = None.""" result = await toolset.read_file('hello.txt') # The actual hash should be a hex string, not 'None' assert 'hash:None' not in result @@ -737,10 +678,7 @@ async def test_read_file_hash_is_real_hash(self, toolset: FileSystemToolset) -> assert f'hash:{expected_hash}' in result async def test_read_file_non_ascii_content(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: errors='replace' removal and errors='XXreplaceXX'. - - With invalid UTF-8 bytes, the tool should not crash — it should use replacement chars. - """ + """With invalid UTF-8 bytes, the tool should not crash — it should use replacement chars.""" # Write raw bytes that are invalid UTF-8 (fs_root / 'broken_utf8.txt').write_bytes(b'hello \xff\xfe world\n') result = await toolset.read_file('broken_utf8.txt') @@ -749,10 +687,7 @@ async def test_read_file_non_ascii_content(self, toolset: FileSystemToolset, fs_ assert 'world' in result async def test_read_file_default_offset_starts_at_first_line(self, toolset: FileSystemToolset) -> None: - """Kill: offset: int = 0 → offset: int = 1 (default param change). - - The first line must be included when no offset is specified. - """ + """The first line must be included when no offset is specified.""" result = await toolset.read_file('multi.txt') # First line must be present (line1) assert ' 1\tline1' in result @@ -760,10 +695,7 @@ async def test_read_file_default_offset_starts_at_first_line(self, toolset: File assert ' 0\t' not in result async def test_toolset_tool_names(self, toolset: FileSystemToolset) -> None: - """Kill: name='read_file' → name=None / name='XXread_fileXX'. - - Verify tools are registered with correct names. - """ + """Verify tools are registered with correct names.""" tool_names = set(toolset.tools.keys()) assert 'read_file' in tool_names assert 'write_file' in tool_names @@ -775,7 +707,6 @@ async def test_toolset_tool_names(self, toolset: FileSystemToolset) -> None: assert 'file_info' in tool_names async def test_write_file_output_format(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: write return string mutations.""" result = await toolset.write_file('fmt.txt', 'ab\ncd\n') # Verify specific format: chars, lines, path, hash assert 'Wrote 6 chars (2 lines) to fmt.txt.' in result @@ -784,28 +715,20 @@ async def test_write_file_output_format(self, toolset: FileSystemToolset, fs_roo assert 'hash:None' not in result async def test_edit_file_output_format(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: edit return string mutations.""" result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Hi') assert result.startswith('Edited hello.txt.') assert 'hash:' in result assert 'hash:None' not in result def test_format_lines_no_double_trailing_newline(self) -> None: - """Kill: result.endswith('\\n') → result.endswith('XX\\nXX'). - - Text that already ends with newline must NOT get a second one appended. - """ + """Text that already ends with newline must NOT get a second one appended.""" text = 'hello\n' result = _format_lines(text, 0, 10) # Exact match: no trailing double newline assert result == ' 1\thello\n' def test_safe_resolve_write_default_is_false(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: _safe_resolve write: bool = False → True. - - Synchronous test to avoid trio crash confusing mutmut. - Protected files should be READABLE via _safe_resolve's default (write=False). - """ + """Protected files should be readable via _safe_resolve's default (write=False).""" (fs_root / '.env.local').write_text('SECRET=x\n') # _safe_resolve without write= uses default write=False → read is allowed resolved = toolset._safe_resolve('.env.local') @@ -815,58 +738,46 @@ def test_safe_resolve_write_default_is_false(self, toolset: FileSystemToolset, f toolset._safe_resolve('.env.local', write=True) async def test_list_directory_exact_size(self, toolset: FileSystemToolset) -> None: - """Kill: size = stat.st_size → size = None.""" result = await toolset.list_directory('.') # hello.txt has 'Hello, world!\n' = 14 bytes assert '14 bytes' in result async def test_list_directory_no_garbage_separator(self, toolset: FileSystemToolset) -> None: - """Kill: '\\n'.join(entries) → 'XX\\nXX'.join(entries).""" result = await toolset.list_directory('.') assert 'XX' not in result async def test_list_directory_error_message(self, toolset: FileSystemToolset) -> None: - """Kill: NotADirectoryError(f'...') → NotADirectoryError(None).""" with pytest.raises(NotADirectoryError, match='Not a directory'): await toolset.list_directory('hello.txt') async def test_find_files_error_message(self, toolset: FileSystemToolset) -> None: - """Kill: NotADirectoryError(f'...') → NotADirectoryError(None).""" with pytest.raises(NotADirectoryError, match='Not a directory'): await toolset.find_files('*.txt', path='hello.txt') async def test_find_files_no_suffix_on_files(self, toolset: FileSystemToolset) -> None: - """Kill: suffix '' → 'XXXX' for non-directory entries.""" - result = await toolset.find_files('*.txt') + result = await toolset.find_files('*') for line in result.splitlines(): if not line.endswith('/'): assert 'XXXX' not in line async def test_find_files_no_garbage_separator(self, toolset: FileSystemToolset) -> None: - """Kill: '\\n'.join(matches) → 'XX\\nXX'.join(matches).""" result = await toolset.find_files('*.txt') assert 'XX' not in result async def test_search_files_no_garbage_separator(self, toolset: FileSystemToolset) -> None: - """Kill: '\\n'.join(results) → 'XX\\nXX'.join(results).""" result = await toolset.search_files(r'line\d') assert 'XX' not in result async def test_file_info_exact_size(self, toolset: FileSystemToolset) -> None: - """Kill: size = stat.st_size → size = None.""" result = await toolset.file_info('hello.txt') assert '14 bytes' in result async def test_file_info_no_garbage_separator(self, toolset: FileSystemToolset) -> None: - """Kill: '\\n'.join(parts) → 'XX\\nXX'.join(parts).""" result = await toolset.file_info('hello.txt') assert 'XX' not in result async def test_search_with_invalid_utf8_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: - """Kill: errors='replace' removal and errors='XXreplaceXX'. - - A file with invalid UTF-8 (but no null bytes = not binary) should be searchable. - """ + """A file with invalid UTF-8 (but no null bytes = not binary) should be searchable.""" # Write a file with invalid UTF-8 but no null bytes (not detected as binary) (fs_root / 'bad_encoding.txt').write_bytes(b'marker_text \xff\xfe end\n') result = await toolset.search_files('marker_text') @@ -874,20 +785,14 @@ async def test_search_with_invalid_utf8_file(self, toolset: FileSystemToolset, f assert 'bad_encoding.txt' in result async def test_search_binary_skip_does_not_stop_iteration(self, toolset: FileSystemToolset) -> None: - """Kill: if _is_binary(raw): continue → break. - - A binary file must be skipped, but subsequent text files must still be searched. - """ + """A binary file must be skipped, but subsequent text files must still be searched.""" # binary.bin exists in the fixture and comes before 'hello.txt' alphabetically result = await toolset.search_files('Hello') # hello.txt must still be found (binary.bin didn't break the loop) assert 'hello.txt' in result async def test_find_hidden_skip_does_not_stop_iteration(self, toolset: FileSystemToolset) -> None: - """Kill: if any(part.startswith('.')): continue → break. - - Hidden files must be skipped, but subsequent visible files must still appear. - """ + """Hidden files must be skipped, but subsequent visible files must still appear.""" # .hidden comes before hello.txt alphabetically — skipping must not break the loop result = await toolset.find_files('*') assert 'hello.txt' in result @@ -919,6 +824,24 @@ def test_protected_defaults(self) -> None: assert '.git/*' in fs.protected_patterns assert '.env' in fs.protected_patterns + def test_non_positive_max_read_lines_rejected(self) -> None: + with pytest.raises(ValueError, match='max_read_lines must be a positive integer'): + FileSystem(max_read_lines=0) + with pytest.raises(ValueError, match='max_read_lines must be a positive integer'): + FileSystem(max_read_lines=-1) + + def test_bool_max_read_lines_rejected(self) -> None: + with pytest.raises(ValueError, match='max_read_lines must be a positive integer'): + FileSystem(max_read_lines=True) # type: ignore[arg-type] + + def test_non_positive_max_search_results_rejected(self) -> None: + with pytest.raises(ValueError, match='max_search_results must be a positive integer'): + FileSystem(max_search_results=0) + + def test_non_positive_max_find_results_rejected(self) -> None: + with pytest.raises(ValueError, match='max_find_results must be a positive integer'): + FileSystem(max_find_results=-1) + @pytest.mark.anyio(backends=['asyncio']) async def test_agent_integration(self, tmp_path: Path, anyio_backend: object) -> None: if str(anyio_backend) != 'asyncio': diff --git a/tests/shell/test_shell.py b/tests/shell/test_shell.py index d8eb644..80eb3fe 100644 --- a/tests/shell/test_shell.py +++ b/tests/shell/test_shell.py @@ -2,9 +2,12 @@ from __future__ import annotations +import os import sys from pathlib import Path +from unittest.mock import MagicMock, patch +import anyio import pytest from pydantic_ai import Agent from pydantic_ai.models.test import TestModel @@ -16,8 +19,10 @@ _is_interactive_command, ) -# ============================================================================ -# ============================================================================ + +def _parse_command_id(result: str) -> str: + assert 'ID: ' in result, f'Expected "ID: " in result: {result!r}' + return result.split('ID: ')[1].strip() class TestIsInteractiveCommand: @@ -81,13 +86,8 @@ def test_leading_spaces(self) -> None: assert _is_interactive_command(' sudo rm') is True -# ============================================================================ -# ============================================================================ - - @pytest.fixture def shell_dir(tmp_path: Path) -> Path: - """Create a temporary directory for shell tests.""" (tmp_path / 'test.txt').write_text('hello\n') (tmp_path / 'subdir').mkdir() (tmp_path / 'subdir' / 'nested.txt').write_text('nested\n') @@ -96,7 +96,6 @@ def shell_dir(tmp_path: Path) -> Path: @pytest.fixture def toolset(shell_dir: Path) -> ShellToolset: - """Create a basic ShellToolset.""" return ShellToolset( cwd=shell_dir, allowed_commands=[], @@ -451,24 +450,6 @@ async def test_exit_code_zero_not_shown(self, toolset: ShellToolset) -> None: result = await toolset.run_command('echo ok') assert 'exit code' not in result - async def test_timeout(self, shell_dir: Path) -> None: - ts = ShellToolset( - cwd=shell_dir, - allowed_commands=[], - denied_commands=[], - denied_operators=[], - default_timeout=0.5, - max_output_chars=50_000, - persist_cwd=False, - allow_interactive=False, - ) - result = await ts.run_command('sleep 10') - assert 'timed out' in result - - async def test_custom_timeout(self, toolset: ShellToolset) -> None: - result = await toolset.run_command('sleep 10', timeout_seconds=0.5) - assert 'timed out' in result - async def test_no_output(self, toolset: ShellToolset) -> None: result = await toolset.run_command('true') assert result == '(no output)' @@ -576,10 +557,6 @@ async def test_nonzero_exit_shows_code(self, toolset: ShellToolset) -> None: result = await toolset.run_command('exit 1') assert '[exit code: 1]' in result - async def test_zero_exit_no_code(self, toolset: ShellToolset) -> None: - result = await toolset.run_command('echo success') - assert 'exit code' not in result - async def test_stdout_stderr_separated_by_newline(self, toolset: ShellToolset) -> None: result = await toolset.run_command('echo out && echo err >&2') assert '[stdout]\nout\n\n[stderr]\nerr' in result @@ -600,10 +577,6 @@ async def test_stdout_chunk_join(self, toolset: ShellToolset) -> None: result = await toolset.run_command(f"{sys.executable} -c \"print('A' * 100 + 'B' * 100)\"") assert 'A' * 100 + 'B' * 100 in result - async def test_exact_no_output_message(self, toolset: ShellToolset) -> None: - result = await toolset.run_command('true') - assert result == '(no output)' - async def test_exit_code_fallback_to_zero(self, shell_dir: Path) -> None: ts = ShellToolset( cwd=shell_dir, @@ -739,7 +712,7 @@ async def test_timeout_with_output_before_timeout(self, shell_dir: Path) -> None assert 'timed out' in result async def test_start_new_session_used(self, shell_dir: Path) -> None: - """Verify the process gets its own session (child is process group leader).""" + """Verify the child is in a different process group from the parent.""" ts = ShellToolset( cwd=shell_dir, allowed_commands=[], @@ -750,7 +723,8 @@ async def test_start_new_session_used(self, shell_dir: Path) -> None: persist_cwd=False, allow_interactive=False, ) - result = await ts.run_command(f'{sys.executable} -c "import os; print(os.getpgid(0) == os.getpid())"') + parent_pgrp = os.getpgrp() + result = await ts.run_command(f'{sys.executable} -c "import os; print(os.getpgrp() != {parent_pgrp})"') assert 'True' in result @@ -769,7 +743,7 @@ async def test_start_command_returns_id(self, shell_dir: Path) -> None: result = await ts.start_command('sleep 100') assert 'ID:' in result assert 'Started background command' in result - command_id = result.split('ID: ')[1].strip() + command_id = _parse_command_id(result) await ts.stop_command(command_id) async def test_check_unknown_id(self, toolset: ShellToolset) -> None: @@ -792,9 +766,7 @@ async def test_start_and_stop(self, shell_dir: Path) -> None: allow_interactive=False, ) start_result = await ts.start_command('echo hello_bg') - command_id = start_result.split('ID: ')[1].strip() - - import anyio + command_id = _parse_command_id(start_result) await anyio.sleep(0.5) @@ -814,7 +786,7 @@ async def test_start_and_check_running(self, shell_dir: Path) -> None: allow_interactive=False, ) start_result = await ts.start_command('sleep 100') - command_id = start_result.split('ID: ')[1].strip() + command_id = _parse_command_id(start_result) check_result = await ts.check_command(command_id) assert 'running' in check_result @@ -833,9 +805,7 @@ async def test_start_and_check_finished(self, shell_dir: Path) -> None: allow_interactive=False, ) start_result = await ts.start_command('echo done_quick') - command_id = start_result.split('ID: ')[1].strip() - - import anyio + command_id = _parse_command_id(start_result) await anyio.sleep(0.5) @@ -871,9 +841,7 @@ async def test_stop_captures_stderr(self, shell_dir: Path) -> None: allow_interactive=False, ) start_result = await ts.start_command('echo err_bg >&2') - command_id = start_result.split('ID: ')[1].strip() - - import anyio + command_id = _parse_command_id(start_result) await anyio.sleep(0.5) @@ -892,9 +860,7 @@ async def test_stop_no_output(self, shell_dir: Path) -> None: allow_interactive=False, ) start_result = await ts.start_command('true') - command_id = start_result.split('ID: ')[1].strip() - - import anyio + command_id = _parse_command_id(start_result) await anyio.sleep(0.5) @@ -913,13 +879,35 @@ async def test_check_no_output_yet(self, shell_dir: Path) -> None: allow_interactive=False, ) start_result = await ts.start_command('sleep 100') - command_id = start_result.split('ID: ')[1].strip() + command_id = _parse_command_id(start_result) check_result = await ts.check_command(command_id) assert 'no output yet' in check_result await ts.stop_command(command_id) + async def test_check_command_captures_stderr(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + start_result = await ts.start_command('echo err_check >&2') + command_id = _parse_command_id(start_result) + + await anyio.sleep(0.5) + + check_result = await ts.check_command(command_id) + assert '[stderr]' in check_result + assert 'err_check' in check_result + + await ts.stop_command(command_id) + async def test_start_command_uses_cwd(self, shell_dir: Path) -> None: ts = ShellToolset( cwd=shell_dir, @@ -932,9 +920,7 @@ async def test_start_command_uses_cwd(self, shell_dir: Path) -> None: allow_interactive=False, ) start_result = await ts.start_command('pwd') - command_id = start_result.split('ID: ')[1].strip() - - import anyio + command_id = _parse_command_id(start_result) await anyio.sleep(0.5) @@ -954,9 +940,7 @@ async def test_stop_removes_from_registry(self, shell_dir: Path) -> None: allow_interactive=False, ) start_result = await ts.start_command('true') - command_id = start_result.split('ID: ')[1].strip() - - import anyio + command_id = _parse_command_id(start_result) await anyio.sleep(0.5) @@ -966,9 +950,82 @@ async def test_stop_removes_from_registry(self, shell_dir: Path) -> None: check_result = await ts.check_command(command_id) assert 'unknown command ID' in check_result + async def test_start_command_cleans_temp_files_on_failure(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + with patch('anyio.open_process', side_effect=OSError('spawn failed')): + with pytest.raises(OSError, match='spawn failed'): + await ts.start_command('echo hi') + assert not ts._background + + async def test_aexit_terminates_background_processes(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.start_command('sleep 300') + command_id = _parse_command_id(result) + bg = ts._background[command_id] + stdout_path = Path(bg.stdout_path) + stderr_path = Path(bg.stderr_path) + assert stdout_path.exists() + assert stderr_path.exists() + + await ts.__aexit__(None, None, None) + + assert not ts._background + assert not stdout_path.exists() + assert not stderr_path.exists() + + async def test_aexit_noop_when_no_background(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + await ts.__aexit__(None, None, None) + assert not ts._background + + async def test_aexit_cleans_already_finished_process(self, shell_dir: Path) -> None: + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + result = await ts.start_command('echo done') + command_id = _parse_command_id(result) + await anyio.sleep(0.5) + # Mark as finished via check_command + await ts.check_command(command_id) + bg = ts._background[command_id] + assert bg.finished -# ============================================================================ -# ============================================================================ + await ts.__aexit__(None, None, None) + assert not ts._background class TestEdgeCases: @@ -1032,7 +1089,7 @@ async def test_persist_cwd_false_skips_sentinel(self, shell_dir: Path) -> None: result = await ts.run_command('echo test') assert _PWD_SENTINEL not in result - async def test_start_new_session_true(self, shell_dir: Path) -> None: + async def test_persist_cwd_semicolon_skips_sentinel(self, shell_dir: Path) -> None: ts = ShellToolset( cwd=shell_dir, allowed_commands=[], @@ -1040,15 +1097,13 @@ async def test_start_new_session_true(self, shell_dir: Path) -> None: denied_operators=[], default_timeout=10.0, max_output_chars=50_000, - persist_cwd=False, + persist_cwd=True, allow_interactive=False, ) - result = await ts.run_command(f'{sys.executable} -c "import os; print(os.getpgid(0) == os.getpid())"') - assert 'True' in result - - -# ============================================================================ -# ============================================================================ + original_cwd = ts._cwd + result = await ts.run_command('echo a ; echo b') + assert _PWD_SENTINEL not in result + assert ts._cwd == original_cwd class TestShellCapability: @@ -1080,7 +1135,281 @@ def test_default_denied_commands(self) -> None: @pytest.mark.anyio(backends=['asyncio']) async def test_agent_integration(self, tmp_path: Path) -> None: + import sniffio + + if sniffio.current_async_library() != 'asyncio': # pragma: no cover + pytest.skip('Agent.run() requires asyncio') model = TestModel(custom_output_text='done', call_tools=[]) agent: Agent[None, str] = Agent(model, capabilities=[Shell(cwd=tmp_path)]) result = await agent.run('run echo hello') assert result.output == 'done' + + +class TestKillProcessGroupEdgeCases: + async def test_sigterm_raises_process_lookup_error(self, tmp_path: Path) -> None: + """When SIGTERM raises ProcessLookupError, method returns without SIGKILL.""" + ts = ShellToolset( + cwd=tmp_path, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=5.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + proc = MagicMock() + proc.pid = 99999 + with patch('os.killpg', side_effect=ProcessLookupError): + await ts._kill_process_group(proc) + # No exception raised, method returned early + + async def test_sigkill_escalation(self, tmp_path: Path) -> None: + """When process doesn't exit within grace period, SIGKILL is sent.""" + ts = ShellToolset( + cwd=tmp_path, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=5.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + proc = MagicMock() + proc.pid = 99999 + + # Make proc.wait() never complete (simulates process ignoring SIGTERM) + async def never_return() -> None: + await anyio.sleep(999) + + proc.wait = never_return + + import signal + + kill_calls: list[tuple[int, int]] = [] + + def fake_killpg(pgid: int, sig: int) -> None: + kill_calls.append((pgid, sig)) + + with ( + patch('os.killpg', side_effect=fake_killpg), + patch('os.getpgid', return_value=12345), + patch('pydantic_ai_harness.shell._toolset._KILL_GRACE_PERIOD', 0.01), + ): + await ts._kill_process_group(proc) + + assert len(kill_calls) == 2 + assert kill_calls[0][1] == signal.SIGTERM + assert kill_calls[1][1] == signal.SIGKILL + + async def test_sigkill_raises_process_lookup_error(self, tmp_path: Path) -> None: + """When SIGKILL raises ProcessLookupError (process exited between SIGTERM and SIGKILL).""" + ts = ShellToolset( + cwd=tmp_path, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=5.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + proc = MagicMock() + proc.pid = 99999 + + async def never_return() -> None: + await anyio.sleep(999) + + proc.wait = never_return + + import signal + + call_count = 0 + + def fake_killpg(pgid: int, sig: int) -> None: + nonlocal call_count + call_count += 1 + if sig == signal.SIGKILL: + raise ProcessLookupError + + with ( + patch('os.killpg', side_effect=fake_killpg), + patch('os.getpgid', return_value=12345), + patch('pydantic_ai_harness.shell._toolset._KILL_GRACE_PERIOD', 0.01), + ): + await ts._kill_process_group(proc) + + assert call_count == 2 + + +class TestDrainWithTimeoutEdgeCases: + async def test_stdout_closed_resource_error(self, tmp_path: Path) -> None: + """ClosedResourceError on stdout is caught silently after yielding data.""" + ts = ShellToolset( + cwd=tmp_path, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=5.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + proc = MagicMock() + + # Yield one chunk then raise ClosedResourceError + class FailingStream: + def __init__(self) -> None: + self._yielded = False + + def __aiter__(self) -> FailingStream: + return self + + async def __anext__(self) -> bytes: + if not self._yielded: + self._yielded = True + return b'partial' + raise anyio.ClosedResourceError + + proc.stdout = FailingStream() + proc.stderr = None + + stdout_chunks: list[bytes] = [] + stderr_chunks: list[bytes] = [] + await ts._drain_with_timeout(stdout_chunks, stderr_chunks, proc) + assert stdout_chunks == [b'partial'] + + async def test_stderr_broken_resource_error(self, tmp_path: Path) -> None: + """BrokenResourceError on stderr is caught silently after yielding data.""" + ts = ShellToolset( + cwd=tmp_path, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=5.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + proc = MagicMock() + proc.stdout = None + + class FailingStream: + def __init__(self) -> None: + self._yielded = False + + def __aiter__(self) -> FailingStream: + return self + + async def __anext__(self) -> bytes: + if not self._yielded: + self._yielded = True + return b'partial' + raise anyio.BrokenResourceError + + proc.stderr = FailingStream() + + stdout_chunks: list[bytes] = [] + stderr_chunks: list[bytes] = [] + await ts._drain_with_timeout(stdout_chunks, stderr_chunks, proc) + assert stderr_chunks == [b'partial'] + + +class TestReadBgOutputEdgeCases: + def test_stdout_oserror(self, tmp_path: Path) -> None: + """OSError reading stdout file returns empty string.""" + ts = ShellToolset( + cwd=tmp_path, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=5.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + bg = MagicMock() + bg.stdout_path = '/nonexistent/path/stdout' + bg.stderr_path = '/nonexistent/path/stderr' + + stdout, stderr = ts._read_bg_output(bg) + assert stdout == '' + assert stderr == '' + + def test_stderr_oserror_only(self, tmp_path: Path) -> None: + """OSError reading stderr file only, stdout succeeds.""" + ts = ShellToolset( + cwd=tmp_path, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=5.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + # Create a valid stdout file but invalid stderr path + stdout_file = tmp_path / 'stdout.txt' + stdout_file.write_text('hello') + + bg = MagicMock() + bg.stdout_path = str(stdout_file) + bg.stderr_path = '/nonexistent/path/stderr' + + stdout, stderr = ts._read_bg_output(bg) + assert stdout == 'hello' + assert stderr == '' + + +class TestCleanupBgFilesEdgeCases: + def test_unlink_oserror(self, tmp_path: Path) -> None: + """OSError on unlink is caught silently.""" + ts = ShellToolset( + cwd=tmp_path, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=5.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + bg = MagicMock() + bg.stdout_path = '/nonexistent/path/stdout' + bg.stderr_path = '/nonexistent/path/stderr' + + # Should not raise + ts._cleanup_bg_files(bg) + + +class TestStopCommandAlreadyFinished: + async def test_stop_already_finished_process(self, shell_dir: Path) -> None: + """stop_command on an already-finished process skips kill.""" + ts = ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + # Start a command that finishes immediately + start_result = await ts.start_command('echo done') + command_id = _parse_command_id(start_result) + + # Wait for the process to finish + await anyio.sleep(0.5) + + # Manually mark as finished with exit_code = None (simulates edge case + # where finished is True but exit_code was never captured) + bg = ts._background[command_id] + bg.finished = True + bg.exit_code = None + + # stop_command should skip the kill branch and handle None exit_code + result = await ts.stop_command(command_id) + assert '[stopped:' in result + assert '[exit code:' not in result diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py index a66e1c6..971604a 100644 --- a/tests/test_placeholder.py +++ b/tests/test_placeholder.py @@ -1,5 +1,7 @@ +import inspect from pathlib import Path +import pytest from pydantic_ai import Agent from pydantic_ai.models.test import TestModel @@ -11,6 +13,25 @@ def test_import(): assert isinstance(pydantic_ai_harness.__all__, list) +def test_lazy_import_filesystem(): + from pydantic_ai_harness import FileSystem + + assert inspect.isclass(FileSystem) + assert hasattr(FileSystem, 'get_toolset') + + +def test_lazy_import_shell(): + from pydantic_ai_harness import Shell + + assert inspect.isclass(Shell) + assert hasattr(Shell, 'get_toolset') + + +def test_lazy_import_unknown(): + with pytest.raises(AttributeError, match='has no attribute'): + pydantic_ai_harness.__getattr__('Nonexistent') + + def test_test_model_fixture(test_model: TestModel): assert isinstance(test_model, TestModel) From dd01ea61e54491e436593aceca2cae5fb0e955d3 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Wed, 27 May 2026 23:56:18 -0500 Subject: [PATCH 03/11] Address non-controversial reviewer feedback on PR #260 - Remove bool guard from __post_init__ validation (pedantic AI slop) - Pre-calculate _real_root in __init__ instead of per-call - Extract _first_matching_pattern helper, use in _check_access - Extract _first_denied_operator helper, use in _check_command - Change _format_lines signature from list[str] to Sequence[str] and split lines once at call site in read_file - write_file: raise FileNotFoundError for missing parents instead of auto-creating - list_directory: add pragma: no cover comment on OSError branch - _check_command docstring: document best-effort security boundary - Remove debug = true from [tool.mutmut] - Update tests to match: remove test_bool_max_read_lines_rejected, change test_write_creates_parents -> test_write_nonexistent_parent_raises, add isolation tests for _first_matching_pattern and _first_denied_operator --- pydantic_ai_harness/filesystem/_capability.py | 2 +- pydantic_ai_harness/filesystem/_toolset.py | 32 ++++++----- pydantic_ai_harness/shell/_toolset.py | 13 ++++- pyproject.toml | 3 -- tests/filesystem/test_filesystem.py | 53 +++++++++++-------- tests/shell/test_shell.py | 29 ++++++++++ 6 files changed, 89 insertions(+), 43 deletions(-) diff --git a/pydantic_ai_harness/filesystem/_capability.py b/pydantic_ai_harness/filesystem/_capability.py index c45868b..bb61ebf 100644 --- a/pydantic_ai_harness/filesystem/_capability.py +++ b/pydantic_ai_harness/filesystem/_capability.py @@ -58,7 +58,7 @@ class FileSystem(AbstractCapability[Any]): def __post_init__(self) -> None: for name in ('max_read_lines', 'max_search_results', 'max_find_results'): value = getattr(self, name) - if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + if not isinstance(value, int) or value <= 0: raise ValueError(f'{name} must be a positive integer, got {value!r}') def get_toolset(self) -> AgentToolset[Any]: diff --git a/pydantic_ai_harness/filesystem/_toolset.py b/pydantic_ai_harness/filesystem/_toolset.py index 51a4ee7..547d1ed 100644 --- a/pydantic_ai_harness/filesystem/_toolset.py +++ b/pydantic_ai_harness/filesystem/_toolset.py @@ -13,9 +13,8 @@ from pydantic_ai.toolsets import FunctionToolset -def _format_lines(text: str, offset: int, limit: int) -> str: - """Format text with line numbers and continuation hint.""" - lines = text.splitlines(keepends=True) +def _format_lines(lines: Sequence[str], offset: int, limit: int) -> str: + """Format pre-split lines with line numbers and continuation hint.""" total = len(lines) if total == 0: @@ -72,6 +71,7 @@ def __init__( ) -> None: super().__init__() self._root = root_dir.resolve() + self._real_root = Path(os.path.realpath(self._root)) self._allowed_patterns = list(allowed_patterns) self._denied_patterns = list(denied_patterns) self._protected_patterns = list(protected_patterns) @@ -88,6 +88,10 @@ def __init__( self.add_function(self.create_directory, name='create_directory') self.add_function(self.file_info, name='file_info') + def _first_matching_pattern(self, path: str, patterns: list[str]) -> str | None: + """Return the first pattern that matches path, or None.""" + return next((p for p in patterns if fnmatch.fnmatch(path, p)), None) + def _resolve_path(self, path: str) -> Path: """Resolve path relative to root, rejecting traversal. @@ -95,8 +99,7 @@ def _resolve_path(self, path: str) -> Path: """ candidate = (self._root / path).resolve() real = Path(os.path.realpath(candidate)) - real_root = Path(os.path.realpath(self._root)) - if not real.is_relative_to(real_root): + if not real.is_relative_to(self._real_root): raise PermissionError(f'Path {path!r} resolves outside the root directory.') return real @@ -104,12 +107,12 @@ def _resolve_path(self, path: str) -> Path: def _check_access(self, path: str, *, write: bool = False) -> None: """Validate path against allow/deny/protected patterns.""" if write and self._protected_patterns: - matched = next((p for p in self._protected_patterns if fnmatch.fnmatch(path, p)), None) + matched = self._first_matching_pattern(path, self._protected_patterns) if matched: raise PermissionError(f'Path {path!r} is protected (matches {matched!r}).') if self._denied_patterns: - matched = next((p for p in self._denied_patterns if fnmatch.fnmatch(path, p)), None) + matched = self._first_matching_pattern(path, self._denied_patterns) if matched: raise PermissionError(f'Path {path!r} is denied by pattern {matched!r}.') @@ -147,11 +150,11 @@ async def read_file(self, path: str, *, offset: int = 0, limit: int | None = Non return f'[Binary file: {size} bytes. Use a binary-aware tool to inspect.]' text = raw.decode('utf-8', errors='replace') - total_lines = len(text.splitlines()) + lines = text.splitlines(keepends=True) content_hash = _content_hash(text) - header = f'[{path} | {total_lines} lines | hash:{content_hash}]\n' - return header + _format_lines(text, offset, limit) + header = f'[{path} | {len(lines)} lines | hash:{content_hash}]\n' + return header + _format_lines(lines, offset, limit) async def write_file(self, path: str, content: str, *, expected_hash: str | None = None) -> str: """Create or overwrite a file with conflict detection. @@ -177,7 +180,9 @@ async def write_file(self, path: str, content: str, *, expected_hash: str | None f'got hash:{current_hash}). Re-read the file and retry.' ) - resolved.parent.mkdir(parents=True, exist_ok=True) + if not resolved.parent.exists(): + parent_rel = str(resolved.parent.relative_to(self._root)) + raise FileNotFoundError(f"Parent directory '{parent_rel}' does not exist. Use create_directory first.") resolved.write_text(content, encoding='utf-8') new_hash = _content_hash(content) lines = len(content.splitlines()) @@ -240,10 +245,9 @@ async def list_directory(self, path: str = '.') -> str: raise NotADirectoryError(f'Not a directory: {path}') entries: list[str] = [] - real_root = Path(os.path.realpath(self._root)) for entry in sorted(resolved.iterdir()): try: - rel = str(entry.relative_to(real_root)) + rel = str(entry.relative_to(self._real_root)) except ValueError: # pragma: no cover continue if entry.is_dir(): @@ -251,7 +255,7 @@ async def list_directory(self, path: str = '.') -> str: else: try: size = entry.stat().st_size - except OSError: # pragma: no cover + except OSError: # pragma: no cover # file deleted between iterdir and stat size = 0 entries.append(f'{rel} ({size} bytes)') return '\n'.join(entries) if entries else '(empty directory)' diff --git a/pydantic_ai_harness/shell/_toolset.py b/pydantic_ai_harness/shell/_toolset.py index 81808af..8a3503c 100644 --- a/pydantic_ai_harness/shell/_toolset.py +++ b/pydantic_ai_harness/shell/_toolset.py @@ -107,12 +107,21 @@ async def __aexit__(self, *args: Any) -> None: self._cleanup_bg_files(bg) self._background.clear() + def _first_denied_operator(self, command: str) -> str | None: + """Return the first denied operator found in command, or None.""" + return next((op for op in self._denied_operators if op in command), None) + def _check_command(self, command: str) -> None: - """Validate command against allow/deny lists.""" + """Validate command against allow/deny lists. + + These checks are best-effort and are not a security boundary — a + sufficiently motivated agent can bypass them. Use OS-level isolation + (containers, sandboxes) for hard enforcement. + """ if not self._allow_interactive and _is_interactive_command(command): raise PermissionError(f'Interactive commands are not allowed. Command: {command!r}') - matched_op = next((op for op in self._denied_operators if op in command), None) + matched_op = self._first_denied_operator(command) if matched_op: raise PermissionError(f'Shell operator {matched_op!r} is not allowed.') diff --git a/pyproject.toml b/pyproject.toml index 405fd7a..d8b6a70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,7 +152,4 @@ also_copy = ['pydantic_ai_harness/', 'tests/'] # Skip trio-parametrized tests during mutation testing — trio segfaults in # mutmut's subprocess environment on Python 3.14 (not a code bug). pytest_add_cli_args = ['-k', 'not trio'] -# Required on Python 3.14 / macOS — mutmut's subprocess workers segfault -# without debug mode. -debug = true # See docs/mutation-testing.md for full results (89.7% kill rate, 60 equivalent mutants). diff --git a/tests/filesystem/test_filesystem.py b/tests/filesystem/test_filesystem.py index bc03e0d..ad9329d 100644 --- a/tests/filesystem/test_filesystem.py +++ b/tests/filesystem/test_filesystem.py @@ -15,14 +15,14 @@ class TestFormatLines: def test_basic_formatting(self) -> None: text = 'line1\nline2\nline3\n' - result = _format_lines(text, 0, 10) + result = _format_lines(text.splitlines(keepends=True), 0, 10) assert ' 1\tline1\n' in result assert ' 2\tline2\n' in result assert ' 3\tline3\n' in result def test_offset(self) -> None: text = 'a\nb\nc\nd\ne\n' - result = _format_lines(text, 2, 2) + result = _format_lines(text.splitlines(keepends=True), 2, 2) assert ' 3\tc\n' in result assert ' 4\td\n' in result assert '... (1 more lines. Use offset=4 to continue reading.)' in result @@ -30,20 +30,20 @@ def test_offset(self) -> None: def test_offset_exceeds_length(self) -> None: text = 'a\nb\n' with pytest.raises(ValueError, match='Offset 5 exceeds file length'): - _format_lines(text, 5, 10) + _format_lines(text.splitlines(keepends=True), 5, 10) def test_empty_file(self) -> None: - result = _format_lines('', 0, 10) + result = _format_lines([], 0, 10) assert result == '(empty file)\n' def test_no_trailing_newline(self) -> None: text = 'no newline' - result = _format_lines(text, 0, 10) + result = _format_lines(text.splitlines(keepends=True), 0, 10) assert result.endswith('\n') def test_continuation_hint(self) -> None: text = '\n'.join(f'line{i}' for i in range(10)) - result = _format_lines(text, 0, 3) + result = _format_lines(text.splitlines(keepends=True), 0, 3) assert '... (7 more lines. Use offset=3 to continue reading.)' in result @@ -133,6 +133,18 @@ async def test_valid_path_resolves(self, toolset: FileSystemToolset, fs_root: Pa result = toolset._resolve_path('hello.txt') assert result == (fs_root / 'hello.txt').resolve() + def test_first_matching_pattern_match(self, toolset: FileSystemToolset) -> None: + result = toolset._first_matching_pattern('secret.key', ['*.txt', '*.key']) + assert result == '*.key' + + def test_first_matching_pattern_no_match(self, toolset: FileSystemToolset) -> None: + result = toolset._first_matching_pattern('readme.md', ['*.txt', '*.key']) + assert result is None + + def test_first_matching_pattern_empty(self, toolset: FileSystemToolset) -> None: + result = toolset._first_matching_pattern('anything.py', []) + assert result is None + async def test_nested_path_resolves(self, toolset: FileSystemToolset) -> None: result = toolset._resolve_path('subdir/nested.py') assert result.name == 'nested.py' @@ -263,10 +275,9 @@ async def test_write_new_file(self, toolset: FileSystemToolset, fs_root: Path) - assert 'Wrote' in result assert (fs_root / 'new.txt').read_text() == 'new content\n' - async def test_write_creates_parents(self, toolset: FileSystemToolset, fs_root: Path) -> None: - result = await toolset.write_file('deep/nested/file.txt', 'deep\n') - assert 'Wrote' in result - assert (fs_root / 'deep' / 'nested' / 'file.txt').read_text() == 'deep\n' + async def test_write_nonexistent_parent_raises(self, toolset: FileSystemToolset) -> None: + with pytest.raises(FileNotFoundError, match="Parent directory 'deep/nested' does not exist"): + await toolset.write_file('deep/nested/file.txt', 'deep\n') async def test_write_overwrite(self, toolset: FileSystemToolset, fs_root: Path) -> None: await toolset.write_file('hello.txt', 'overwritten\n') @@ -514,34 +525,34 @@ class TestMutationKillers: async def test_format_lines_offset_equals_total(self) -> None: text = 'a\nb\n' # 2 lines with pytest.raises(ValueError, match='Offset 2 exceeds file length'): - _format_lines(text, 2, 10) + _format_lines(text.splitlines(keepends=True), 2, 10) async def test_format_lines_exact_fit_no_continuation(self) -> None: text = 'a\nb\nc\n' # 3 lines - result = _format_lines(text, 0, 3) + result = _format_lines(text.splitlines(keepends=True), 0, 3) assert '... (' not in result assert 'more lines' not in result async def test_format_lines_exact_fit_from_offset(self) -> None: text = 'a\nb\nc\n' # 3 lines - result = _format_lines(text, 1, 2) # lines 2-3, 0 remaining + result = _format_lines(text.splitlines(keepends=True), 1, 2) # lines 2-3, 0 remaining assert '... (' not in result assert 'more lines' not in result async def test_format_lines_one_line_remaining(self) -> None: text = 'a\nb\nc\n' # 3 lines - result = _format_lines(text, 0, 2) + result = _format_lines(text.splitlines(keepends=True), 0, 2) assert '... (1 more lines. Use offset=2 to continue reading.)' in result async def test_format_lines_line_number_starts_at_one(self) -> None: text = 'first\nsecond\n' - result = _format_lines(text, 0, 10) + result = _format_lines(text.splitlines(keepends=True), 0, 10) assert ' 1\tfirst\n' in result assert ' 0\t' not in result async def test_format_lines_offset_line_numbering(self) -> None: text = 'a\nb\nc\n' - result = _format_lines(text, 1, 2) + result = _format_lines(text.splitlines(keepends=True), 1, 2) assert ' 2\tb\n' in result assert ' 3\tc\n' in result @@ -658,13 +669,13 @@ async def test_safe_resolve_passes_write_flag(self, toolset: FileSystemToolset, async def test_format_lines_join_separator(self) -> None: """Verify the result doesn't contain garbage between lines.""" text = 'a\nb\nc\n' - result = _format_lines(text, 0, 3) + result = _format_lines(text.splitlines(keepends=True), 0, 3) # Lines should be directly adjacent (no separator between them) assert ' 1\ta\n 2\tb\n 3\tc\n' in result async def test_format_lines_no_trailing_newline_preserves_content(self) -> None: text = 'no newline' - result = _format_lines(text, 0, 10) + result = _format_lines(text.splitlines(keepends=True), 0, 10) # The content must still be present assert 'no newline' in result assert result.endswith('\n') @@ -723,7 +734,7 @@ async def test_edit_file_output_format(self, toolset: FileSystemToolset, fs_root def test_format_lines_no_double_trailing_newline(self) -> None: """Text that already ends with newline must NOT get a second one appended.""" text = 'hello\n' - result = _format_lines(text, 0, 10) + result = _format_lines(text.splitlines(keepends=True), 0, 10) # Exact match: no trailing double newline assert result == ' 1\thello\n' @@ -830,10 +841,6 @@ def test_non_positive_max_read_lines_rejected(self) -> None: with pytest.raises(ValueError, match='max_read_lines must be a positive integer'): FileSystem(max_read_lines=-1) - def test_bool_max_read_lines_rejected(self) -> None: - with pytest.raises(ValueError, match='max_read_lines must be a positive integer'): - FileSystem(max_read_lines=True) # type: ignore[arg-type] - def test_non_positive_max_search_results_rejected(self) -> None: with pytest.raises(ValueError, match='max_search_results must be a positive integer'): FileSystem(max_search_results=0) diff --git a/tests/shell/test_shell.py b/tests/shell/test_shell.py index 80eb3fe..1df1b23 100644 --- a/tests/shell/test_shell.py +++ b/tests/shell/test_shell.py @@ -244,6 +244,35 @@ async def test_empty_tokens(self, shell_dir: Path) -> None: ) ts._check_command('') + def test_first_denied_operator_match(self, toolset: ShellToolset) -> None: + ts = ShellToolset( + cwd=Path('/tmp'), + allowed_commands=[], + denied_commands=[], + denied_operators=['|', '>'], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + assert ts._first_denied_operator('echo hi | cat') == '|' + + def test_first_denied_operator_no_match(self, toolset: ShellToolset) -> None: + ts = ShellToolset( + cwd=Path('/tmp'), + allowed_commands=[], + denied_commands=[], + denied_operators=['|', '>'], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=False, + allow_interactive=False, + ) + assert ts._first_denied_operator('echo hello') is None + + def test_first_denied_operator_empty_list(self, toolset: ShellToolset) -> None: + assert toolset._first_denied_operator('echo hi | cat') is None + class TestTruncation: def test_within_limit(self, toolset: ShellToolset) -> None: From a269e6c77dddff358f74832bbd1424f514071475 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Mon, 1 Jun 2026 12:18:18 -0500 Subject: [PATCH 04/11] Move mutmut out of dev deps into a one-off script mutmut pulls in a large dependency tree and is only used to validate test quality, not for normal development or CI. Keep its config in pyproject.toml (mutmut v3 has no CLI flag to override the config path) but install it ephemerally via 'uv run --with' from a small script. The [tool.mutmut] block is unchanged: paths_to_mutate, tests_dir, also_copy, and pytest_add_cli_args still live in pyproject.toml. Only the dev dependency is removed. docs/mutation-testing.md is updated to reference the new script. --- docs/mutation-testing.md | 11 +- pyproject.toml | 1 - scripts/run-mutmut.sh | 24 ++++ uv.lock | 269 --------------------------------------- 4 files changed, 30 insertions(+), 275 deletions(-) create mode 100755 scripts/run-mutmut.sh diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index 1e356d3..4fb8f35 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -1,11 +1,12 @@ # Mutation Testing Results > Generated from commit `bd268c8` on 2026-05-26. Results may become stale as code -> evolves — regenerate via `uv run mutmut run --max-children 1`. +> evolves — regenerate via `scripts/run-mutmut.sh run --max-children 1`. Covers `pydantic_ai_harness/filesystem/_toolset.py` and `pydantic_ai_harness/shell/_toolset.py`. -Run with [mutmut](https://mutmut.readthedocs.io/) v3 (`uv run mutmut run --max-children 1`). +Run with [mutmut](https://mutmut.readthedocs.io/) v3 via `scripts/run-mutmut.sh` (which +installs mutmut ephemerally with `uv run --with` — no dev dependency required). ## Summary @@ -42,7 +43,7 @@ tests exercise the same code paths as the asyncio tests. ## Running ```bash -uv run mutmut run --max-children 1 -uv run mutmut results -uv run mutmut show +scripts/run-mutmut.sh run --max-children 1 +scripts/run-mutmut.sh results +scripts/run-mutmut.sh show ``` diff --git a/pyproject.toml b/pyproject.toml index d8b6a70..7b94dc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,6 @@ dev = [ 'logfire[httpx]>=4.31.0', "dirty-equals>=0.9.0", "inline-snapshot>=0.32.5", - "mutmut>=3.5.0", ] lint = [ 'ruff>=0.14', diff --git a/scripts/run-mutmut.sh b/scripts/run-mutmut.sh new file mode 100755 index 0000000..ec8b13a --- /dev/null +++ b/scripts/run-mutmut.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# One-off mutation testing runner. +# +# mutmut is intentionally not a project dev dependency: it pulls in a large +# tree and is only needed when validating test quality. Install it ephemerally +# via `uv run --with` and invoke it as a subcommand. +# +# Config (paths_to_mutate, tests_dir, also_copy, pytest_add_cli_args) lives in +# [tool.mutmut] in pyproject.toml — mutmut v3 reads it from CWD by default. +# +# Usage: +# scripts/run-mutmut.sh # run all mutants +# scripts/run-mutmut.sh results # show pass/fail summary +# scripts/run-mutmut.sh show # inspect a specific mutant +# scripts/run-mutmut.sh --max-children 4 run # any mutmut flag works +# +# Pair with `make testcov` to keep coverage at 100% — surviving mutants usually +# indicate missing test cases for boundary conditions. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +uv run --with "mutmut>=3.5.0" -- mutmut "$@" diff --git a/uv.lock b/uv.lock index 877a7a4..1249a74 100644 --- a/uv.lock +++ b/uv.lock @@ -565,86 +565,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/2c/4b209e9dd6700cea0c0e39d7e5e70e9f494f817a374174a823bd11561d31/inline_snapshot-0.33.0-py3-none-any.whl", hash = "sha256:76b8c2c5899d27d3d464d1160eb3b8eee179ba635bb80a8e5e93220f10b60207", size = 89625, upload-time = "2026-05-12T18:39:46.43Z" }, ] -[[package]] -name = "libcst" -version = "1.8.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, - { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/52/97d5454dee9d014821fe0c88f3dc0e83131b97dd074a4d49537056a75475/libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9", size = 2211698, upload-time = "2025-11-03T22:31:50.117Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a4/d1205985d378164687af3247a9c8f8bdb96278b0686ac98ab951bc6d336a/libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6", size = 2093104, upload-time = "2025-11-03T22:31:52.189Z" }, - { url = "https://files.pythonhosted.org/packages/9e/de/1338da681b7625b51e584922576d54f1b8db8fc7ff4dc79121afc5d4d2cd/libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58", size = 2237419, upload-time = "2025-11-03T22:31:53.526Z" }, - { url = "https://files.pythonhosted.org/packages/50/06/ee66f2d83b870534756e593d464d8b33b0914c224dff3a407e0f74dc04e0/libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8", size = 2300820, upload-time = "2025-11-03T22:31:55.995Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ca/959088729de8e0eac8dd516e4fb8623d8d92bad539060fa85c9e94d418a5/libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba", size = 2301201, upload-time = "2025-11-03T22:31:57.347Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4c/2a21a8c452436097dfe1da277f738c3517f3f728713f16d84b9a3d67ca8d/libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b", size = 2408213, upload-time = "2025-11-03T22:31:59.221Z" }, - { url = "https://files.pythonhosted.org/packages/3e/26/8f7b671fad38a515bb20b038718fd2221ab658299119ac9bcec56c2ced27/libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073", size = 2119189, upload-time = "2025-11-03T22:32:00.696Z" }, - { url = "https://files.pythonhosted.org/packages/5b/bf/ffb23a48e27001165cc5c81c5d9b3d6583b21b7f5449109e03a0020b060c/libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100", size = 2001736, upload-time = "2025-11-03T22:32:02.986Z" }, - { url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289, upload-time = "2025-11-03T22:32:04.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927, upload-time = "2025-11-03T22:32:06.209Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002, upload-time = "2025-11-03T22:32:07.559Z" }, - { url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048, upload-time = "2025-11-03T22:32:08.869Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675, upload-time = "2025-11-03T22:32:10.579Z" }, - { url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934, upload-time = "2025-11-03T22:32:11.856Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247, upload-time = "2025-11-03T22:32:13.279Z" }, - { url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774, upload-time = "2025-11-03T22:32:14.647Z" }, - { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, - { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, - { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, - { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, - { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, - { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, - { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, - { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, - { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, - { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, - { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, - { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, - { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, - { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, - { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, - { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, - { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, - { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, - { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, - { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, - { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, - { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, - { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, -] - -[[package]] -name = "linkify-it-py" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, -] - [[package]] name = "logfire" version = "4.33.0" @@ -690,23 +610,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] - -[[package]] -name = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -716,24 +619,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mutmut" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "coverage" }, - { name = "libcst" }, - { name = "pytest" }, - { name = "setproctitle" }, - { name = "textual" }, - { name = "toml", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/0d/9ce4fc8b219504a336eb814c5a7ea8e379ad93ce05327ff3842aea93bf0b/mutmut-3.5.0.tar.gz", hash = "sha256:548186d4b0c494b7b9895db82871cb1f229b9271c9ff7cd633e348dd9afcc772", size = 36389, upload-time = "2026-02-22T18:46:41.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/23/ac475f6db39643946feb09290a2178d603d2b623034d56d3f5059cddb769/mutmut-3.5.0-py3-none-any.whl", hash = "sha256:f19f2dd2e977eb9dc17255d8cb11e24fbfc3191620fba3108cac25779c9d78c9", size = 34242, upload-time = "2026-02-22T18:46:43.113Z" }, -] - [[package]] name = "nexus-rpc" version = "1.4.0" @@ -898,15 +783,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "platformdirs" -version = "4.9.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -1063,7 +939,6 @@ dev = [ { name = "dirty-equals" }, { name = "inline-snapshot" }, { name = "logfire", extra = ["httpx"] }, - { name = "mutmut" }, { name = "pydantic-ai-harness", extra = ["code-mode"] }, { name = "pytest" }, { name = "pytest-anyio" }, @@ -1090,7 +965,6 @@ dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "inline-snapshot", specifier = ">=0.32.5" }, { name = "logfire", extras = ["httpx"], specifier = ">=4.31.0" }, - { name = "mutmut", specifier = ">=3.5.0" }, { name = "pydantic-ai-harness", extras = ["code-mode"] }, { name = "pytest", specifier = ">=9.0.0" }, { name = "pytest-anyio" }, @@ -1458,30 +1332,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "pyyaml-ft" -version = "8.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, - { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, - { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, - { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, - { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, - { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, - { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, -] - [[package]] name = "requests" version = "2.34.0" @@ -1535,90 +1385,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] -[[package]] -name = "setproctitle" -version = "1.3.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/48/fb401ec8c4953d519d05c87feca816ad668b8258448ff60579ac7a1c1386/setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b", size = 18079, upload-time = "2025-09-05T12:49:07.732Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a3/c2b0333c2716fb3b4c9a973dd113366ac51b4f8d56b500f4f8f704b4817a/setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7", size = 13099, upload-time = "2025-09-05T12:49:09.222Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f8/17bda581c517678260e6541b600eeb67745f53596dc077174141ba2f6702/setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c", size = 31793, upload-time = "2025-09-05T12:49:10.297Z" }, - { url = "https://files.pythonhosted.org/packages/27/d1/76a33ae80d4e788ecab9eb9b53db03e81cfc95367ec7e3fbf4989962fedd/setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3", size = 32779, upload-time = "2025-09-05T12:49:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/59/27/1a07c38121967061564f5e0884414a5ab11a783260450172d4fc68c15621/setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f", size = 34578, upload-time = "2025-09-05T12:49:13.393Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d4/725e6353935962d8bb12cbf7e7abba1d0d738c7f6935f90239d8e1ccf913/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64", size = 32030, upload-time = "2025-09-05T12:49:15.362Z" }, - { url = "https://files.pythonhosted.org/packages/67/24/e4677ae8e1cb0d549ab558b12db10c175a889be0974c589c428fece5433e/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5", size = 33363, upload-time = "2025-09-05T12:49:16.829Z" }, - { url = "https://files.pythonhosted.org/packages/55/d4/69ce66e4373a48fdbb37489f3ded476bb393e27f514968c3a69a67343ae0/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381", size = 31508, upload-time = "2025-09-05T12:49:18.032Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5a/42c1ed0e9665d068146a68326529b5686a1881c8b9197c2664db4baf6aeb/setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c", size = 12558, upload-time = "2025-09-05T12:49:19.742Z" }, - { url = "https://files.pythonhosted.org/packages/dc/fe/dd206cc19a25561921456f6cb12b405635319299b6f366e0bebe872abc18/setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95", size = 13245, upload-time = "2025-09-05T12:49:21.023Z" }, - { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, - { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, - { url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" }, - { url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" }, - { url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, - { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, - { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, - { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, - { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, - { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, - { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, - { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, - { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, - { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, - { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, - { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, - { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, - { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, - { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, - { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, - { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, - { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, - { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, - { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, - { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, - { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, - { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, - { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, - { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, - { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, - { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, - { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, - { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, - { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, - { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, - { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, - { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, - { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, - { url = "https://files.pythonhosted.org/packages/34/8a/aff5506ce89bc3168cb492b18ba45573158d528184e8a9759a05a09088a9/setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6", size = 12654, upload-time = "2025-09-05T12:51:17.141Z" }, - { url = "https://files.pythonhosted.org/packages/41/89/5b6f2faedd6ced3d3c085a5efbd91380fb1f61f4c12bc42acad37932f4e9/setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2", size = 14284, upload-time = "2025-09-05T12:51:18.393Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c0/4312fed3ca393a29589603fd48f17937b4ed0638b923bac75a728382e730/setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78", size = 13282, upload-time = "2025-09-05T12:51:19.703Z" }, - { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, - { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, -] - [[package]] name = "shellingham" version = "1.5.4" @@ -1735,32 +1501,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/e6/a147fad980d0f92b7b070d4fe439310b91375592c26d8cb6dc5d1a1c0ae4/temporalio-1.27.1-cp310-abi3-win_amd64.whl", hash = "sha256:a3afaed09643cfb24ac04837144ff37a02a0eac7eeeb1876065066806aeda512", size = 14979491, upload-time = "2026-05-13T16:20:50.622Z" }, ] -[[package]] -name = "textual" -version = "8.2.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify"] }, - { name = "mdit-py-plugins" }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, -] - -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - [[package]] name = "tomli" version = "2.4.1" @@ -1899,15 +1639,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] -[[package]] -name = "uc-micro-py" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" From 961452a8639fab9fc2327001c58577fe9467d410 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Mon, 1 Jun 2026 13:07:25 -0500 Subject: [PATCH 05/11] Replace getattr with direct field access in FileSystem.__post_init__ strawgate flagged 'getattr(self, name)' in __post_init__ as 'bad claude'. The runtime isinstance validation is still useful (dataclass field annotations are advisory, not enforced) but the getattr is unnecessary and obscures the intent. Iterate fields directly via a typed dict so pyright doesn't narrow the isinstance check away. --- pydantic_ai_harness/filesystem/_capability.py | 10 ++++++++-- tests/filesystem/test_filesystem.py | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/pydantic_ai_harness/filesystem/_capability.py b/pydantic_ai_harness/filesystem/_capability.py index bb61ebf..c160a35 100644 --- a/pydantic_ai_harness/filesystem/_capability.py +++ b/pydantic_ai_harness/filesystem/_capability.py @@ -56,8 +56,14 @@ class FileSystem(AbstractCapability[Any]): """Maximum number of matches returned by `find_files`.""" def __post_init__(self) -> None: - for name in ('max_read_lines', 'max_search_results', 'max_find_results'): - value = getattr(self, name) + # Runtime validation: dataclass field annotations are advisory, not enforced. + # A config-driven caller could pass a string that would otherwise propagate. + values: dict[str, Any] = { + 'max_read_lines': self.max_read_lines, + 'max_search_results': self.max_search_results, + 'max_find_results': self.max_find_results, + } + for name, value in values.items(): if not isinstance(value, int) or value <= 0: raise ValueError(f'{name} must be a positive integer, got {value!r}') diff --git a/tests/filesystem/test_filesystem.py b/tests/filesystem/test_filesystem.py index ad9329d..b940509 100644 --- a/tests/filesystem/test_filesystem.py +++ b/tests/filesystem/test_filesystem.py @@ -849,6 +849,12 @@ def test_non_positive_max_find_results_rejected(self) -> None: with pytest.raises(ValueError, match='max_find_results must be a positive integer'): FileSystem(max_find_results=-1) + def test_non_integer_max_read_lines_rejected(self) -> None: + # Runtime validation: dataclass annotations are advisory, so a string + # slipped in from a config must be rejected, not propagated. + with pytest.raises(ValueError, match='max_read_lines must be a positive integer'): + FileSystem(max_read_lines='1000') # type: ignore[arg-type] + @pytest.mark.anyio(backends=['asyncio']) async def test_agent_integration(self, tmp_path: Path, anyio_backend: object) -> None: if str(anyio_backend) != 'asyncio': From a7eed3dc003cfce6689237f4bcce24d893b0d0b8 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Mon, 1 Jun 2026 13:10:51 -0500 Subject: [PATCH 06/11] Replace field-name references with literal defaults in docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strawgate flagged that 'default: max_read_lines' in a docstring is unhelpful — readers can't tell what the actual default is without scrolling to the class definition. Replace with the literal value in both filesystem and shell toolsets. --- pydantic_ai_harness/filesystem/_toolset.py | 2 +- pydantic_ai_harness/shell/_toolset.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic_ai_harness/filesystem/_toolset.py b/pydantic_ai_harness/filesystem/_toolset.py index 547d1ed..72b2498 100644 --- a/pydantic_ai_harness/filesystem/_toolset.py +++ b/pydantic_ai_harness/filesystem/_toolset.py @@ -131,7 +131,7 @@ async def read_file(self, path: str, *, offset: int = 0, limit: int | None = Non Args: path: File path relative to the root directory. offset: Zero-based line offset to start reading from. - limit: Maximum number of lines to return (default: max_read_lines). + limit: Maximum number of lines to return (default: 2000). Returns: File content with line numbers, plus metadata header. diff --git a/pydantic_ai_harness/shell/_toolset.py b/pydantic_ai_harness/shell/_toolset.py index 8a3503c..ff3a171 100644 --- a/pydantic_ai_harness/shell/_toolset.py +++ b/pydantic_ai_harness/shell/_toolset.py @@ -232,7 +232,7 @@ async def run_command(self, command: str, *, timeout_seconds: float | None = Non Args: command: The shell command to run. - timeout_seconds: Maximum seconds to wait (default: default_timeout). + timeout_seconds: Maximum seconds to wait (default: 30). Returns: Labeled stdout/stderr output with exit code on non-zero exit. From d6a6ee5d0f3ea2e0c2ee8164b7954c2b120b19d2 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Mon, 1 Jun 2026 13:39:01 -0500 Subject: [PATCH 07/11] filter recursive listings/searches by protected and denied patterns list_directory, search_files, and find_files previously only checked the root path passed to the tool, so a recursive rglob could return or read through files that the agent would otherwise be denied direct access to. Add _is_accessible(rel, write=True) as a predicate form of _check_access and call it on each entry walked by the three recursive operations, so denied and protected patterns hide children the same way they block direct read/write. Reads of protected files remain allowed in isolation; the listing operations pass write=True to make existence match the read-side policy that protected paths can't be opened. --- pydantic_ai_harness/filesystem/_toolset.py | 32 ++++ tests/filesystem/test_filesystem.py | 172 +++++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/pydantic_ai_harness/filesystem/_toolset.py b/pydantic_ai_harness/filesystem/_toolset.py index 72b2498..4043555 100644 --- a/pydantic_ai_harness/filesystem/_toolset.py +++ b/pydantic_ai_harness/filesystem/_toolset.py @@ -120,6 +120,23 @@ def _check_access(self, path: str, *, write: bool = False) -> None: if not any(fnmatch.fnmatch(path, p) for p in self._allowed_patterns): raise PermissionError(f'Path {path!r} does not match any allowed pattern.') + def _is_accessible(self, path: str, *, write: bool = False) -> bool: + """Predicate form of `_check_access` for filtering recursive walkers. + + Used by `list_directory`, `search_files`, and `find_files` to skip + children that would be rejected if accessed directly. Note this only + checks the relative path against patterns; it does not resolve symlinks. + """ + if write and self._protected_patterns: + if self._first_matching_pattern(path, self._protected_patterns) is not None: + return False + if self._denied_patterns: + if self._first_matching_pattern(path, self._denied_patterns) is not None: + return False + if self._allowed_patterns and not any(fnmatch.fnmatch(path, p) for p in self._allowed_patterns): + return False + return True + def _safe_resolve(self, path: str, *, write: bool = False) -> Path: """Resolve and access-check a path in one step.""" self._check_access(path, write=write) @@ -250,6 +267,11 @@ async def list_directory(self, path: str = '.') -> str: rel = str(entry.relative_to(self._real_root)) except ValueError: # pragma: no cover continue + # Apply the same allow/deny/protected filtering used for direct + # access so a directory listing can't leak patterns the agent + # couldn't otherwise read or write. + if not self._is_accessible(rel, write=True): + continue if entry.is_dir(): entries.append(f'{rel}/') else: @@ -295,6 +317,11 @@ async def search_files(self, pattern: str, *, path: str = '.', include_glob: str if any(part.startswith('.') for part in rel_parts): continue rel_str = str(file_path.relative_to(real_root)) + # Apply the same allow/deny/protected filtering used for direct + # access so a recursive search can't read patterns the agent + # couldn't otherwise read. + if not self._is_accessible(rel_str, write=True): + continue if include_glob and not fnmatch.fnmatch(rel_str, include_glob): continue try: @@ -337,6 +364,11 @@ async def find_files(self, pattern: str, *, path: str = '.') -> str: if any(part.startswith('.') for part in rel_parts): continue rel = str(match.relative_to(real_root)) + # Apply the same allow/deny/protected filtering used for direct + # access so a glob find can't surface patterns the agent + # couldn't otherwise see. + if not self._is_accessible(rel, write=True): + continue suffix = '/' if match.is_dir() else '' matches.append(f'{rel}{suffix}') if len(matches) >= self._max_find_results: diff --git a/tests/filesystem/test_filesystem.py b/tests/filesystem/test_filesystem.py index b940509..e531582 100644 --- a/tests/filesystem/test_filesystem.py +++ b/tests/filesystem/test_filesystem.py @@ -232,6 +232,62 @@ async def test_access_with_no_denied_patterns(self, fs_root: Path) -> None: # No denied, no protected, no allowed → should pass for any path ts._check_access('anything.txt', write=True) + async def test_is_accessible_no_patterns(self, fs_root: Path) -> None: + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + assert ts._is_accessible('anything.txt') + assert ts._is_accessible('anything.txt', write=True) + + async def test_is_accessible_protected_only_on_write(self, fs_root: Path) -> None: + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=['.env', '.env.*'], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + # Reads ignore the protected list — they only block writes. + assert ts._is_accessible('.env') + assert ts._is_accessible('.env', write=True) is False + # A non-protected path passes the protected check even with write=True, + # so the walker falls through to the allowed/denied check. + assert ts._is_accessible('hello.txt', write=True) + + async def test_is_accessible_denied(self, fs_root: Path) -> None: + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=['*.secret'], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + assert ts._is_accessible('visible.txt') + assert ts._is_accessible('creds.secret') is False + + async def test_is_accessible_allowed_list_excludes(self, fs_root: Path) -> None: + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=['*.py'], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + assert ts._is_accessible('main.py') + assert ts._is_accessible('README.md') is False + class TestReadFile: async def test_read_basic(self, toolset: FileSystemToolset) -> None: @@ -370,6 +426,40 @@ async def test_list_empty_directory(self, toolset: FileSystemToolset, fs_root: P result = await toolset.list_directory('empty') assert result == '(empty directory)' + async def test_list_hides_protected_entries(self, fs_root: Path) -> None: + # .env is protected by the default toolset fixture; .git is hidden by + # the dotfile filter, but a directory that is itself explicitly + # protected is also hidden from listings. + (fs_root / 'visible.txt').write_text('ok\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=['.env', '.env.*'], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + result = await ts.list_directory('.') + assert 'visible.txt' in result + assert '.env' not in result + + async def test_list_hides_denied_entries(self, fs_root: Path) -> None: + (fs_root / 'visible.txt').write_text('ok\n') + (fs_root / 'creds.secret').write_text('hunter2\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=['*.secret'], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + result = await ts.list_directory('.') + assert 'visible.txt' in result + assert 'creds.secret' not in result + class TestSearchFiles: async def test_search_basic(self, toolset: FileSystemToolset) -> None: @@ -424,6 +514,48 @@ async def test_search_truncation(self, fs_root: Path) -> None: result = await ts.search_files('findme') assert 'truncated at 50 matches' in result + async def test_search_skips_protected_contents(self, fs_root: Path) -> None: + # The .env file has matching content but should be filtered by the + # recursive walker before its bytes are read. + (fs_root / 'visible.txt').write_text('SECRET=matchme\n') + (fs_root / '.env').write_text('SECRET=matchme\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=['.env', '.env.*'], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + result = await ts.search_files('matchme') + assert 'visible.txt' in result + assert '.env' not in result + + async def test_search_skips_denied_files(self, fs_root: Path) -> None: + (fs_root / 'visible.txt').write_text('lookhere\n') + (fs_root / 'creds.secret').write_text('lookhere\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=['*.secret'], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + result = await ts.search_files('lookhere') + assert 'visible.txt' in result + assert 'creds.secret' not in result + + async def test_search_only_matches_allowed_files(self, fs_root: Path) -> None: + # Allowed-pattern filtering for recursive search is exercised by + # `test_is_accessible_allowed_list_excludes` and the + # toolset-level behavior; an end-to-end search requires + # `allowed_patterns` to also accept the root path, which is a + # pre-existing access-control limitation independent of this fix. + pass + class TestFindFiles: async def test_find_glob(self, toolset: FileSystemToolset) -> None: @@ -471,6 +603,46 @@ async def test_find_truncation(self, fs_root: Path) -> None: result = await ts.find_files('*.dat') assert 'truncated at 5 matches' in result + async def test_find_hides_protected_entries(self, fs_root: Path) -> None: + (fs_root / 'visible.txt').write_text('ok\n') + (fs_root / '.env').write_text('SECRET=abc\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=['.env', '.env.*'], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + result = await ts.find_files('*') + assert 'visible.txt' in result + assert '.env' not in result + + async def test_find_hides_denied_entries(self, fs_root: Path) -> None: + (fs_root / 'visible.txt').write_text('ok\n') + (fs_root / 'creds.secret').write_text('hunter2\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=['*.secret'], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + result = await ts.find_files('*') + assert 'visible.txt' in result + assert 'creds.secret' not in result + + async def test_find_only_shows_allowed_entries(self, fs_root: Path) -> None: + # Allowed-pattern filtering for recursive find is exercised by + # `test_is_accessible_allowed_list_excludes` and the + # toolset-level behavior; an end-to-end find requires + # `allowed_patterns` to also accept the root path, which is a + # pre-existing access-control limitation independent of this fix. + pass + class TestCreateDirectory: async def test_create_basic(self, toolset: FileSystemToolset, fs_root: Path) -> None: From ea12712e3b3dca922194a013f5d2d1429cf2b452 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:37:02 +0000 Subject: [PATCH 08/11] fix(filesystem): let walkers list under a file-shaped allowlist; add toolset docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file pattern like `src/*.py` in `allowed_patterns` is a per-file rule, but the walkers gated their *root* directory on it too — so `list_directory('.')`, `search_files('.', ...)` and `find_files('*')` always raised, because a directory root never matches a file pattern. The toolset was effectively unusable whenever an allowlist was set. Walkers now skip the allowlist gate on their root (deny/protected patterns still apply) and filter each entry instead, matching how the dotfile and deny/protected filters already behave. The two stubbed tests that documented this limitation are now real end-to-end assertions. Also add README docs for the filesystem and shell toolsets and trim the mutation-testing doc down to the durable how-to-run guidance, dropping the frozen, already-stale result snapshot. --- README.md | 4 +- docs/mutation-testing.md | 74 ++++++----- pydantic_ai_harness/filesystem/README.md | 135 +++++++++++++++++++++ pydantic_ai_harness/filesystem/_toolset.py | 32 +++-- pydantic_ai_harness/shell/README.md | 127 +++++++++++++++++++ tests/filesystem/test_filesystem.py | 62 ++++++++-- 6 files changed, 374 insertions(+), 60 deletions(-) create mode 100644 pydantic_ai_harness/filesystem/README.md create mode 100644 pydantic_ai_harness/shell/README.md diff --git a/README.md b/README.md index 37fee2c..4d4e151 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,8 @@ We studied leading coding agents, agent frameworks, and Claw-style assistants to |---|---|---|---|---| | **Tools & execution** | **Code mode** | Sandboxed Python execution via [Monty](https://github.com/pydantic/monty) -- one `run_code` call replaces N tool calls | :white_check_mark: [Docs](pydantic_ai_harness/code_mode/) | | | | **Tool search** | Progressive tool discovery for large tool sets | :white_check_mark: [Pydantic AI](https://pydantic.dev/docs/ai/tools-toolsets/toolsets/#deferred-loading) | | -| | **File system** | Read, write, edit, search files with path traversal prevention | :construction: [PR #177](https://github.com/pydantic/pydantic-ai-harness/pull/177) | [pydantic-ai-backend](https://github.com/vstorm-co/pydantic-ai-backend) (vstorm‑co) | -| | **Shell** | Execute commands with allowlists, denylists, and timeouts | :construction: [PR #177](https://github.com/pydantic/pydantic-ai-harness/pull/177) | [pydantic-ai-backend](https://github.com/vstorm-co/pydantic-ai-backend) (vstorm‑co) | +| | **File system** | Read, write, edit, search files with path traversal prevention | :white_check_mark: [Docs](pydantic_ai_harness/filesystem/) | [pydantic-ai-backend](https://github.com/vstorm-co/pydantic-ai-backend) (vstorm‑co) | +| | **Shell** | Execute commands with allowlists, denylists, and timeouts | :white_check_mark: [Docs](pydantic_ai_harness/shell/) | [pydantic-ai-backend](https://github.com/vstorm-co/pydantic-ai-backend) (vstorm‑co) | | | **Repo context injection** | Auto-load CLAUDE.md/AGENTS.md and repo structure | :construction: [PR #175](https://github.com/pydantic/pydantic-ai-harness/pull/175) | [pydantic-deep](https://github.com/vstorm-co/pydantic-deepagents) (vstorm‑co) | | | **Verification loop** | Run tests after edits, auto-fix failures | :construction: [PR #169](https://github.com/pydantic/pydantic-ai-harness/pull/169) | | | **Context management** | **Sliding window** | Trim conversation history to stay within token limits | :construction: [PR #191](https://github.com/pydantic/pydantic-ai-harness/pull/191) | [summarization-pydantic-ai](https://github.com/vstorm-co/summarization-pydantic-ai) (vstorm‑co) | diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index 4fb8f35..f7112c8 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -1,49 +1,47 @@ -# Mutation Testing Results +# Mutation Testing -> Generated from commit `bd268c8` on 2026-05-26. Results may become stale as code -> evolves — regenerate via `scripts/run-mutmut.sh run --max-children 1`. +Mutation testing complements the 100% branch-coverage requirement: coverage +proves every line and branch runs, mutation testing proves the assertions +actually pin the behavior down. -Covers `pydantic_ai_harness/filesystem/_toolset.py` and `pydantic_ai_harness/shell/_toolset.py`. +Covers `pydantic_ai_harness/filesystem/_toolset.py` and +`pydantic_ai_harness/shell/_toolset.py`. -Run with [mutmut](https://mutmut.readthedocs.io/) v3 via `scripts/run-mutmut.sh` (which -installs mutmut ephemerally with `uv run --with` — no dev dependency required). +Run with [mutmut](https://mutmut.readthedocs.io/) v3 via `scripts/run-mutmut.sh`, +which installs mutmut ephemerally with `uv run --with` — no dev dependency +required. -## Summary +```bash +scripts/run-mutmut.sh run --max-children 1 +scripts/run-mutmut.sh results +scripts/run-mutmut.sh show +``` -| Metric | Value | -|---|---| -| Total mutants | 584 | -| Killed | 524 | -| Survived | 60 | -| Kill rate | **89.7%** | +## Interpreting survivors -## Equivalent Mutants (60 survivors) +A surviving mutant is either a missing test or an equivalent mutant — a change +that produces behavior no test could distinguish from the original. Triage each +survivor; the recurring equivalent-mutant categories in this codebase are: -All 60 survivors are provably equivalent — no test can distinguish them from the original. +- **Trampoline default params** — mutmut v3 wraps functions, and the wrapper + keeps the original defaults, so a mutated default is never observed. +- **Omitted `name=` in `add_function()`** — pydantic-ai falls back to + `method.__name__`, which equals the explicit name being mutated away. +- **`'utf-8'` encoding mutations** — Python's codec lookup is case-insensitive + and UTF-8 is the default text encoding, so case/omission changes are no-ops. +- **`errors='replace'` mutations** — exercised only by invalid bytes; valid + UTF-8 test data never invokes the error handler. +- **Unreachable `except` blocks** (marked `pragma: no cover`) — paths that + can't be triggered in the test environment. +- **`CancelScope(shield=True)` flips** — require an outer cancellation during + the near-instant cleanup window. -| Category | Count | Why unkillable | -|---|---|---| -| Trampoline default params | 7 | mutmut v3 wraps functions; wrapper keeps original defaults, so mutated defaults are never observed | -| `name=None` / omitted in `add_function()` | 18 | pydantic-ai falls back to `method.__name__`, which equals the original explicit name | -| Encoding case `'utf-8'` → `'UTF-8'` | 10 | Python's codec lookup is case-insensitive | -| Encoding omit/`None` (`utf-8` is default) | 11 | Default text encoding is UTF-8 on all supported platforms | -| Unreachable `except` blocks (`pragma: no cover`) | 6 | `except ValueError/OSError` paths can't be triggered in the test environment | -| `replace()` count removed/changed | 2 | Count is pre-validated as exactly 1 before the call | -| `CancelScope(shield=True)` → `False`/`None` | 2 | Requires an outer cancellation to fire during the ~instant cleanup window | -| Dead `returncode` branch | 1 | `proc.returncode` is never `None` after `await proc.wait()` | -| `errors='replace'` mutations | 3 | Test data is valid UTF-8; the error handler is never invoked | +Anything outside these categories should be treated as a real gap and killed +with a new test. ## Limitations -Trio-parametrized tests are excluded during mutation testing (`-k 'not trio'` in -`pyproject.toml [tool.mutmut]`) because trio segfaults in mutmut's subprocess -environment on Python 3.14 / macOS. This does not affect the kill rate — trio -tests exercise the same code paths as the asyncio tests. - -## Running - -```bash -scripts/run-mutmut.sh run --max-children 1 -scripts/run-mutmut.sh results -scripts/run-mutmut.sh show -``` +Trio-parametrized tests are excluded during mutation testing (`-k 'not trio'` +in `pyproject.toml [tool.mutmut]`) because trio segfaults in mutmut's +subprocess environment on Python 3.14 / macOS. The kill rate is unaffected — +the trio tests exercise the same code paths as the asyncio tests. diff --git a/pydantic_ai_harness/filesystem/README.md b/pydantic_ai_harness/filesystem/README.md new file mode 100644 index 0000000..b980d0d --- /dev/null +++ b/pydantic_ai_harness/filesystem/README.md @@ -0,0 +1,135 @@ +# FileSystem + +Give an agent sandboxed, pattern-filtered access to a directory tree. + +## The problem + +Letting an agent touch the filesystem directly is risky: path traversal +(`../../etc/passwd`), symlinks that escape the project, clobbering `.git`, or +leaking `.env` secrets. Hand-rolling the guards around every tool call is +repetitive and easy to get subtly wrong. + +## The solution + +`FileSystem` exposes a fixed set of file tools, all scoped to a single +`root_dir`. Every path is resolved and containment-checked (symlinks included) +before any I/O, and access is filtered through allow / deny / protected glob +patterns. + +```python +from pydantic_ai import Agent +from pydantic_ai_harness import FileSystem + +agent = Agent( + 'anthropic:claude-sonnet-4-6', + capabilities=[FileSystem(root_dir='./workspace')], +) + +result = agent.run_sync('Read config.toml and tell me the package name.') +print(result.output) +``` + +## Tools + +| Tool | Purpose | +|---|---| +| `read_file` | Read a text file with line numbers and a content hash. Binary files are detected and not dumped. | +| `write_file` | Create or overwrite a file. Optional `expected_hash` rejects stale writes (optimistic concurrency). | +| `edit_file` | Exact-string replacement; `old_text` must match exactly once. Optional `expected_hash`. | +| `list_directory` | List a directory's entries with type indicators and sizes. | +| `search_files` | Regex search over file contents, optionally narrowed by an `include_glob`. | +| `find_files` | Glob search over file names (e.g. `*.py`, `**/*.json`). | +| `create_directory` | Create a directory and any missing parents. | +| `file_info` | Metadata for a file or directory (size, type, line count, hash, symlink target). | + +## Security model + +- **Containment.** Paths resolve relative to `root_dir`; anything resolving + outside — via `..`, an absolute path, or a symlink — is rejected. Symlinks + are resolved with `os.path.realpath` *before* the containment check, closing + the TOCTTOU window. +- **Binary detection.** `read_file` returns a placeholder instead of dumping + binary bytes into the model context. +- **Optimistic concurrency.** `write_file`/`edit_file` accept an + `expected_hash` so an agent operating on a stale read is told to re-read + rather than silently overwriting newer content. + +## Pattern filtering + +Three independent glob lists control access. Patterns are matched with +`fnmatch`, whose `*` spans `/`, so `*.py` matches `src/main.py` and you rarely +need `**`. + +| Field | Effect | +|---|---| +| `allowed_patterns` | If non-empty, only matching paths are accessible (allowlist). | +| `denied_patterns` | Matching paths are always rejected (denylist). | +| `protected_patterns` | Matching paths are read-only — reads succeed, writes are rejected. | + +`protected_patterns` defaults to `.git/`, `.env`/`.env.*`, `*.pem`, `*.key`, +and `**/secrets*`. Pass an empty list to disable protection. + +### Direct access vs. walkers + +The three rules apply at two different granularities: + +- **Direct access** (`read_file`, `write_file`, `edit_file`, `file_info`, + `create_directory`) gates the operation's target path. You must name a path + that the patterns permit. +- **Walkers** (`list_directory`, `search_files`, `find_files`) gate their root + by deny/protected patterns, but **not** by `allowed_patterns` — a directory + root like `.` never matches a file pattern such as `src/*.py`, so requiring + it to would make every listing fail. Instead, the root is always walked and + each **entry** is filtered against all three lists. A directory listing can + never surface a path the agent couldn't otherwise read or write. + +So with `allowed_patterns=['*.py']`, `list_directory('.')` succeeds and shows +only the `.py` entries; `read_file('notes.md')` is rejected. + +> Dotfiles and dot-directories are skipped by `search_files` and `find_files` +> regardless of patterns. + +## Configuration + +```python +FileSystem( + root_dir='.', # str | Path — sandbox root + allowed_patterns=[], # allowlist globs (empty = allow all) + denied_patterns=[], # denylist globs + protected_patterns=[...], # read-only globs (defaults to secrets/.git) + max_read_lines=2000, # cap for a single read_file + max_search_results=1000, # cap for search_files + max_find_results=1000, # cap for find_files +) +``` + +The integer limits must be positive; they are validated at construction. + +## Agent spec (YAML/JSON) + +`FileSystem` works with Pydantic AI's +[agent spec](https://ai.pydantic.dev/agent-spec/): + +```yaml +# agent.yaml +model: anthropic:claude-sonnet-4-6 +capabilities: + - FileSystem: + root_dir: ./workspace + allowed_patterns: ['*.py', '*.toml'] +``` + +```python +from pydantic_ai import Agent +from pydantic_ai_harness import FileSystem + +agent = Agent.from_file('agent.yaml', custom_capability_types=[FileSystem]) +``` + +Pass `custom_capability_types` so the spec loader knows how to instantiate +`FileSystem`. + +## Further reading + +- [Pydantic AI capabilities](https://ai.pydantic.dev/capabilities/) +- [Toolsets](https://ai.pydantic.dev/toolsets/) diff --git a/pydantic_ai_harness/filesystem/_toolset.py b/pydantic_ai_harness/filesystem/_toolset.py index 4043555..103dbc2 100644 --- a/pydantic_ai_harness/filesystem/_toolset.py +++ b/pydantic_ai_harness/filesystem/_toolset.py @@ -104,8 +104,17 @@ def _resolve_path(self, path: str) -> Path: return real - def _check_access(self, path: str, *, write: bool = False) -> None: - """Validate path against allow/deny/protected patterns.""" + def _check_access(self, path: str, *, write: bool = False, check_allowed: bool = True) -> None: + """Validate path against allow/deny/protected patterns. + + `check_allowed=False` skips the `allowed_patterns` gate. Walkers + (`list_directory`, `search_files`, `find_files`) pass it so their root + directory isn't required to match `allowed_patterns` itself — `.` or + `src` would never match a file pattern like `src/*.py`. The walk's + entries are still filtered against `allowed_patterns` per-entry via + `_is_accessible`. Denied and protected patterns continue to gate the + root. + """ if write and self._protected_patterns: matched = self._first_matching_pattern(path, self._protected_patterns) if matched: @@ -116,7 +125,7 @@ def _check_access(self, path: str, *, write: bool = False) -> None: if matched: raise PermissionError(f'Path {path!r} is denied by pattern {matched!r}.') - if self._allowed_patterns: + if check_allowed and self._allowed_patterns: if not any(fnmatch.fnmatch(path, p) for p in self._allowed_patterns): raise PermissionError(f'Path {path!r} does not match any allowed pattern.') @@ -137,9 +146,9 @@ def _is_accessible(self, path: str, *, write: bool = False) -> bool: return False return True - def _safe_resolve(self, path: str, *, write: bool = False) -> Path: + def _safe_resolve(self, path: str, *, write: bool = False, check_allowed: bool = True) -> Path: """Resolve and access-check a path in one step.""" - self._check_access(path, write=write) + self._check_access(path, write=write, check_allowed=check_allowed) return self._resolve_path(path) async def read_file(self, path: str, *, offset: int = 0, limit: int | None = None) -> str: @@ -257,7 +266,10 @@ async def list_directory(self, path: str = '.') -> str: Returns: A newline-separated listing with type indicators and sizes. """ - resolved = self._safe_resolve(path) + # The listing root is gated by denied/protected patterns but not by + # allowed_patterns: a directory like '.' never matches a file pattern. + # Entries are filtered per-entry against allowed_patterns below. + resolved = self._safe_resolve(path, check_allowed=False) if not resolved.is_dir(): raise NotADirectoryError(f'Not a directory: {path}') @@ -293,7 +305,9 @@ async def search_files(self, pattern: str, *, path: str = '.', include_glob: str Returns: Matching lines formatted as file:line_number:text. """ - resolved = self._safe_resolve(path) + # See list_directory: the search root isn't gated by allowed_patterns; + # matched files are filtered per-entry below. + resolved = self._safe_resolve(path, check_allowed=False) try: compiled = re.compile(pattern) except re.error as e: @@ -350,7 +364,9 @@ async def find_files(self, pattern: str, *, path: str = '.') -> str: Returns: Newline-separated list of matching file paths relative to root. """ - resolved = self._safe_resolve(path) + # See list_directory: the find root isn't gated by allowed_patterns; + # matched entries are filtered per-entry below. + resolved = self._safe_resolve(path, check_allowed=False) if not resolved.is_dir(): raise NotADirectoryError(f'Not a directory: {path}') diff --git a/pydantic_ai_harness/shell/README.md b/pydantic_ai_harness/shell/README.md new file mode 100644 index 0000000..fd013da --- /dev/null +++ b/pydantic_ai_harness/shell/README.md @@ -0,0 +1,127 @@ +# Shell + +Give an agent the ability to run shell commands, with allow/deny controls and +managed background processes. + +## The problem + +Agents frequently need to run a build, a test suite, a linter, or a quick +`grep`. Wiring up subprocess handling — streaming output, timeouts, truncation, +killing runaway processes, and cleaning up background jobs at the end of a run — +is fiddly boilerplate that every agent reinvents. + +## The solution + +`Shell` exposes command-execution tools rooted at a working directory, with +configurable allow/deny lists and automatic cleanup of background processes +when the agent run ends. + +```python +from pydantic_ai import Agent +from pydantic_ai_harness import Shell + +agent = Agent( + 'anthropic:claude-sonnet-4-6', + capabilities=[Shell(cwd='./workspace', allowed_commands=['ls', 'cat', 'rg'])], +) + +result = agent.run_sync('List the Python files and summarize the largest one.') +print(result.output) +``` + +## Tools + +| Tool | Purpose | +|---|---| +| `run_command` | Run a command synchronously and return labelled stdout/stderr plus exit code. Honors a per-call or default timeout. | +| `start_command` | Launch a long-running command (server, watcher) in the background; returns an ID. | +| `check_command` | Report the status and accumulated output of a background command. | +| `stop_command` | Terminate a background command and return its final output. | + +Output is labelled with `[stdout]` / `[stderr]` markers and an `[exit code: N]` +line on non-zero exit, then truncated to `max_output_chars`. + +## Command controls + +| Field | Effect | +|---|---| +| `allowed_commands` | If non-empty, only these executables may run (allowlist). | +| `denied_commands` | These executables are always rejected (denylist). | +| `denied_operators` | Shell operators (e.g. `>`, `>>`, `|`) that are rejected when present. | +| `allow_interactive` | If `False` (default), commands that expect a TTY (`vi`, `sudo`, `ssh`, …) are blocked. | + +`allowed_commands` and `denied_commands` are mutually exclusive — set one, not +both. `denied_commands` defaults to a list of destructive commands (`rm`, +`rmdir`, `mkfs`, `dd`, `shutdown`, `reboot`, …); pass an empty list to disable. +The executable name is extracted with `shlex`, so arguments don't bypass the +check. + +> **These checks are best-effort, not a security boundary.** A sufficiently +> motivated agent can defeat them (e.g. `bash -c '...'`, env-var indirection). +> For hard guarantees, run the agent inside OS-level isolation — a container or +> sandbox. + +## Background processes + +`start_command` writes stdout/stderr to temp files and returns a short ID. Use +`check_command(id)` to poll and `stop_command(id)` to terminate and collect +final output. Processes are launched in their own session (`start_new_session`) +so the whole process group can be signalled — `SIGTERM`, escalating to +`SIGKILL` after a grace period. + +On run end, the toolset's `__aexit__` terminates every still-running background +process and deletes its temp files. The agent runtime enters toolsets via an +`AsyncExitStack`, so this cleanup runs whether the run succeeds or raises — an +agent that forgets to call `stop_command` won't leak processes. + +## Working directory + +By default each command runs in `cwd` and `cd` has no lasting effect. Set +`persist_cwd=True` to make `cd` sticky: the toolset appends a `pwd` sentinel to +successful commands, parses the result, and carries the new directory into +subsequent calls. Commands containing `;` skip the sentinel injection so the +`&&`-gated sentinel can't be bypassed. + +## Configuration + +```python +Shell( + cwd='.', # str | Path — working directory + allowed_commands=[], # allowlist (mutually exclusive with denied) + denied_commands=[...], # denylist (defaults to destructive commands) + denied_operators=[], # blocked shell operators + default_timeout=30.0, # seconds, per run_command + max_output_chars=50_000, # output cap returned to the model + persist_cwd=False, # make cd sticky across calls + allow_interactive=False, # allow TTY-style commands +) +``` + +## Agent spec (YAML/JSON) + +`Shell` works with Pydantic AI's +[agent spec](https://ai.pydantic.dev/agent-spec/): + +```yaml +# agent.yaml +model: anthropic:claude-sonnet-4-6 +capabilities: + - Shell: + cwd: ./workspace + allowed_commands: ['ls', 'cat', 'rg', 'pytest'] +``` + +```python +from pydantic_ai import Agent +from pydantic_ai_harness import Shell + +agent = Agent.from_file('agent.yaml', custom_capability_types=[Shell]) +``` + +Pass `custom_capability_types` so the spec loader knows how to instantiate +`Shell`. + +## Further reading + +- [Pydantic AI capabilities](https://ai.pydantic.dev/capabilities/) +- [Toolsets](https://ai.pydantic.dev/toolsets/) diff --git a/tests/filesystem/test_filesystem.py b/tests/filesystem/test_filesystem.py index e531582..7b3923e 100644 --- a/tests/filesystem/test_filesystem.py +++ b/tests/filesystem/test_filesystem.py @@ -444,6 +444,24 @@ async def test_list_hides_protected_entries(self, fs_root: Path) -> None: assert 'visible.txt' in result assert '.env' not in result + async def test_list_root_allowed_patterns_filters_entries(self, fs_root: Path) -> None: + # A file-shaped allowed pattern must not make the root unlistable: '.' + # is always listed, and entries are filtered against the pattern. + (fs_root / 'keep.py').write_text('ok\n') + (fs_root / 'skip.md').write_text('ok\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=['*.py'], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + result = await ts.list_directory('.') + assert 'keep.py' in result + assert 'skip.md' not in result + async def test_list_hides_denied_entries(self, fs_root: Path) -> None: (fs_root / 'visible.txt').write_text('ok\n') (fs_root / 'creds.secret').write_text('hunter2\n') @@ -549,12 +567,22 @@ async def test_search_skips_denied_files(self, fs_root: Path) -> None: assert 'creds.secret' not in result async def test_search_only_matches_allowed_files(self, fs_root: Path) -> None: - # Allowed-pattern filtering for recursive search is exercised by - # `test_is_accessible_allowed_list_excludes` and the - # toolset-level behavior; an end-to-end search requires - # `allowed_patterns` to also accept the root path, which is a - # pre-existing access-control limitation independent of this fix. - pass + # The search root ('.') isn't required to match allowed_patterns; only + # the matched files are filtered against it per-entry. + (fs_root / 'keep.py').write_text('findme\n') + (fs_root / 'skip.md').write_text('findme\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=['*.py'], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + result = await ts.search_files('findme') + assert 'keep.py' in result + assert 'skip.md' not in result class TestFindFiles: @@ -636,12 +664,22 @@ async def test_find_hides_denied_entries(self, fs_root: Path) -> None: assert 'creds.secret' not in result async def test_find_only_shows_allowed_entries(self, fs_root: Path) -> None: - # Allowed-pattern filtering for recursive find is exercised by - # `test_is_accessible_allowed_list_excludes` and the - # toolset-level behavior; an end-to-end find requires - # `allowed_patterns` to also accept the root path, which is a - # pre-existing access-control limitation independent of this fix. - pass + # The find root ('.') isn't required to match allowed_patterns; only + # the matched entries are filtered against it per-entry. + (fs_root / 'keep.py').write_text('ok\n') + (fs_root / 'skip.md').write_text('ok\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=['*.py'], + denied_patterns=[], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + result = await ts.find_files('*') + assert 'keep.py' in result + assert 'skip.md' not in result class TestCreateDirectory: From b8a5fdc35c2a01474ed768427517ebae25761490 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:36:20 +0000 Subject: [PATCH 09/11] fix(filesystem): hide dotfiles in list_directory for walker consistency list_directory surfaced dot-entries (.git/, .env, .hidden) while search_files and find_files deliberately skip any dot-prefixed path component, so the three walkers disagreed on what exists and a listing leaked .git/. Apply the same dotfile skip to list_directory; this also makes the existing test_list_hides_protected_entries comment ("hidden by the dotfile filter") true. --- pydantic_ai_harness/filesystem/README.md | 3 ++- pydantic_ai_harness/filesystem/_toolset.py | 7 ++++++- tests/filesystem/test_filesystem.py | 7 +++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pydantic_ai_harness/filesystem/README.md b/pydantic_ai_harness/filesystem/README.md index b980d0d..5a8b0a1 100644 --- a/pydantic_ai_harness/filesystem/README.md +++ b/pydantic_ai_harness/filesystem/README.md @@ -86,7 +86,8 @@ The three rules apply at two different granularities: So with `allowed_patterns=['*.py']`, `list_directory('.')` succeeds and shows only the `.py` entries; `read_file('notes.md')` is rejected. -> Dotfiles and dot-directories are skipped by `search_files` and `find_files` +> Dotfiles and dot-directories (`.git`, `.env`, `.github`, …) are skipped by +> all three walkers — `list_directory`, `search_files`, and `find_files` — > regardless of patterns. ## Configuration diff --git a/pydantic_ai_harness/filesystem/_toolset.py b/pydantic_ai_harness/filesystem/_toolset.py index 103dbc2..a01fb0e 100644 --- a/pydantic_ai_harness/filesystem/_toolset.py +++ b/pydantic_ai_harness/filesystem/_toolset.py @@ -276,9 +276,14 @@ async def list_directory(self, path: str = '.') -> str: entries: list[str] = [] for entry in sorted(resolved.iterdir()): try: - rel = str(entry.relative_to(self._real_root)) + rel_path = entry.relative_to(self._real_root) except ValueError: # pragma: no cover continue + # Skip dotfiles and dot-directories, matching search_files and + # find_files so the three walkers agree on what exists. + if any(part.startswith('.') for part in rel_path.parts): + continue + rel = str(rel_path) # Apply the same allow/deny/protected filtering used for direct # access so a directory listing can't leak patterns the agent # couldn't otherwise read or write. diff --git a/tests/filesystem/test_filesystem.py b/tests/filesystem/test_filesystem.py index 7b3923e..7c59a5d 100644 --- a/tests/filesystem/test_filesystem.py +++ b/tests/filesystem/test_filesystem.py @@ -413,6 +413,13 @@ async def test_list_not_a_dir(self, toolset: FileSystemToolset) -> None: with pytest.raises(NotADirectoryError): await toolset.list_directory('hello.txt') + async def test_list_skips_hidden(self, toolset: FileSystemToolset) -> None: + # Dotfiles/dot-directories are hidden, matching find_files/search_files. + result = await toolset.list_directory('.') + assert 'hello.txt' in result + assert '.hidden' not in result + assert '.git' not in result + async def test_list_shows_sizes(self, toolset: FileSystemToolset) -> None: result = await toolset.list_directory('.') assert 'bytes' in result From bb9974f3c7cb065302214dafcaea1019d5080547 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 23:11:30 +0000 Subject: [PATCH 10/11] fix(shell): keep the tail when truncating command output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Truncation dropped everything past max_output_chars from the start, discarding exactly the part that matters most — the end of stdout, the [stderr] section (appended last), and exit info — which is where errors and stack traces land. Keep the final max_output_chars instead and drop the head. This also removes the convoluted stderr-budget branch, since the tail naturally preserves stderr. Addresses review feedback on PR #260. --- pydantic_ai_harness/shell/README.md | 4 +- pydantic_ai_harness/shell/_toolset.py | 20 ++++----- tests/shell/test_shell.py | 60 +++++++-------------------- 3 files changed, 28 insertions(+), 56 deletions(-) diff --git a/pydantic_ai_harness/shell/README.md b/pydantic_ai_harness/shell/README.md index fd013da..f34c7fa 100644 --- a/pydantic_ai_harness/shell/README.md +++ b/pydantic_ai_harness/shell/README.md @@ -39,7 +39,9 @@ print(result.output) | `stop_command` | Terminate a background command and return its final output. | Output is labelled with `[stdout]` / `[stderr]` markers and an `[exit code: N]` -line on non-zero exit, then truncated to `max_output_chars`. +line on non-zero exit. When it exceeds `max_output_chars` the **tail** is kept +(the head is dropped), so errors, stack traces, and the `[stderr]` section — +which all land at the end — survive truncation. ## Command controls diff --git a/pydantic_ai_harness/shell/_toolset.py b/pydantic_ai_harness/shell/_toolset.py index ff3a171..afcae12 100644 --- a/pydantic_ai_harness/shell/_toolset.py +++ b/pydantic_ai_harness/shell/_toolset.py @@ -138,17 +138,17 @@ def _check_command(self, command: str) -> None: if self._allowed_commands and executable not in self._allowed_commands: raise PermissionError(f'Command {executable!r} is not in the allowed list.') - def _truncate(self, text: str, *, stderr_text: str = '') -> str: - """Truncate output, reserving space for stderr when both streams are present.""" + def _truncate(self, text: str) -> str: + """Truncate output to the configured cap, keeping the tail. + + The most useful output — errors, stack traces, exit info, and the + `[stderr]` section (which callers append last) — lands at the end, so + the head is dropped and the final `max_output_chars` are kept. + """ if len(text) <= self._max_output_chars: return text - if not stderr_text: - return text[: self._max_output_chars] + f'\n[... output truncated at {self._max_output_chars} chars]' - - stderr_budget = min(len(stderr_text) + len('[stderr]\n'), self._max_output_chars // 3) - stdout_budget = self._max_output_chars - stderr_budget - truncated = text[:stdout_budget] + f'\n[... stdout truncated at {stdout_budget} chars]' - return truncated + marker = f'[... output truncated, showing last {self._max_output_chars} chars]\n' + return marker + text[-self._max_output_chars :] def _wrap_command_for_cwd(self, command: str) -> str: """Append pwd sentinel to command for cwd tracking. @@ -293,7 +293,7 @@ async def _read_stderr() -> None: parts.append(f'[stderr]\n{stderr}') output = '\n'.join(parts) if parts else '(no output)' - output = self._truncate(output, stderr_text=stderr) + output = self._truncate(output) exit_code = proc.returncode if proc.returncode is not None else 0 if self._persist_cwd and exit_code == 0 and new_cwd is not None: diff --git a/tests/shell/test_shell.py b/tests/shell/test_shell.py index 1df1b23..12fcdf2 100644 --- a/tests/shell/test_shell.py +++ b/tests/shell/test_shell.py @@ -304,8 +304,8 @@ def test_over_limit(self, shell_dir: Path) -> None: allow_interactive=False, ) result = ts._truncate('x' * 20) - assert result.startswith('x' * 10) - assert 'truncated at 10 chars' in result + assert result.endswith('x' * 10) + assert 'truncated, showing last 10 chars' in result def test_exactly_at_limit_not_truncated(self, shell_dir: Path) -> None: ts = ShellToolset( @@ -334,42 +334,28 @@ def test_one_over_limit_truncated(self, shell_dir: Path) -> None: allow_interactive=False, ) result = ts._truncate('x' * 11) - assert result.startswith('x' * 10) - assert 'truncated at 10 chars' in result + assert result.endswith('x' * 10) + assert 'truncated, showing last 10 chars' in result - def test_smart_truncation_with_stderr(self, shell_dir: Path) -> None: - """When stderr_text is provided and output is over limit, use smart truncation.""" + def test_keeps_tail_not_head(self, shell_dir: Path) -> None: + """The tail (where errors and the [stderr] section land) is preserved.""" ts = ShellToolset( cwd=shell_dir, allowed_commands=[], denied_commands=[], denied_operators=[], default_timeout=10.0, - max_output_chars=100, + max_output_chars=20, persist_cwd=False, allow_interactive=False, ) - long_text = 'x' * 200 - result = ts._truncate(long_text, stderr_text='error msg') - assert 'stdout truncated' in result - assert len(result) < 200 + text = 'HEAD' + 'x' * 100 + 'TAIL_ERROR' + result = ts._truncate(text) + assert result.endswith('TAIL_ERROR') + assert 'HEAD' not in result + assert 'truncated' in result - def test_smart_truncation_not_triggered_under_limit(self, shell_dir: Path) -> None: - """When under limit, stderr_text parameter is irrelevant.""" - ts = ShellToolset( - cwd=shell_dir, - allowed_commands=[], - denied_commands=[], - denied_operators=[], - default_timeout=10.0, - max_output_chars=100, - persist_cwd=False, - allow_interactive=False, - ) - result = ts._truncate('short', stderr_text='error') - assert result == 'short' - - def test_truncation_without_stderr_uses_basic(self, shell_dir: Path) -> None: + def test_truncation_marker_wording(self, shell_dir: Path) -> None: ts = ShellToolset( cwd=shell_dir, allowed_commands=[], @@ -381,23 +367,7 @@ def test_truncation_without_stderr_uses_basic(self, shell_dir: Path) -> None: allow_interactive=False, ) result = ts._truncate('x' * 20) - assert 'output truncated at 10 chars' in result - assert 'stdout truncated' not in result - - def test_truncation_with_stderr_uses_smart(self, shell_dir: Path) -> None: - ts = ShellToolset( - cwd=shell_dir, - allowed_commands=[], - denied_commands=[], - denied_operators=[], - default_timeout=10.0, - max_output_chars=10, - persist_cwd=False, - allow_interactive=False, - ) - result = ts._truncate('x' * 20, stderr_text='err') - assert 'stdout truncated' in result - assert 'output truncated' not in result + assert 'output truncated, showing last 10 chars' in result class TestCwdSentinel: @@ -495,7 +465,7 @@ async def test_output_truncation(self, shell_dir: Path) -> None: allow_interactive=False, ) result = await ts.run_command(f'{sys.executable} -c "print(\'x\' * 200)"') - assert 'truncated at 50 chars' in result + assert 'truncated, showing last 50 chars' in result async def test_persist_cwd(self, shell_dir: Path) -> None: ts = ShellToolset( From c9542ba683dc7c45bf1bb51341d1b0439c2e774f Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:29:57 -0500 Subject: [PATCH 11/11] fix(filesystem,shell): recoverable errors, per-run isolation, cwd hardening, generic typing Address blockers from PR review so the capabilities are robust under real agent use, not just under TestModel: - B2: tool methods raised native exceptions that pyai propagates and aborts the run on. A `_recoverable` decorator now surfaces model-correctable errors (missing file, denied path, stale edit, denied command) as `ModelRetry` so the agent can self-correct. Internal helpers keep raising native exceptions. - B3: ShellToolset held mutable per-run state (`_cwd`, `_background`) on the single instance `get_toolset` builds at construction, so concurrent runs corrupted each other. `for_run` now returns a fresh copy, matching the CodeModeToolset exemplar. - B4: persist_cwd parsed cwd from stdout, which a `;` could silently disable and command output could spoof. Capture `pwd` out-of-band via a private temp file instead. - Sec#3: pattern matching ran against the agent-supplied string, letting `config/./secret.txt` evade a deny rule, and `**/secrets*` missed root-level files (leaking them via search). Match the canonical path; treat a leading `**/` as covering the root. - D2: parametrize FileSystem/Shell and their toolsets on `AgentDepsT` instead of `Any`, matching the rest of the library. Co-Authored-By: Claude Opus 4.8 (1M context) --- pydantic_ai_harness/filesystem/_capability.py | 7 +- pydantic_ai_harness/filesystem/_toolset.py | 76 ++++- pydantic_ai_harness/shell/_capability.py | 8 +- pydantic_ai_harness/shell/_toolset.py | 225 ++++++++------ tests/filesystem/test_filesystem.py | 273 +++++++++-------- tests/shell/test_shell.py | 280 +++++++++--------- 6 files changed, 510 insertions(+), 359 deletions(-) diff --git a/pydantic_ai_harness/filesystem/_capability.py b/pydantic_ai_harness/filesystem/_capability.py index c160a35..218b875 100644 --- a/pydantic_ai_harness/filesystem/_capability.py +++ b/pydantic_ai_harness/filesystem/_capability.py @@ -8,6 +8,7 @@ from typing import Any from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.tools import AgentDepsT from pydantic_ai.toolsets import AgentToolset from pydantic_ai_harness.filesystem._toolset import FileSystemToolset @@ -23,7 +24,7 @@ @dataclass -class FileSystem(AbstractCapability[Any]): +class FileSystem(AbstractCapability[AgentDepsT]): """File system access scoped to a root directory. All paths are resolved relative to `root_dir`. Traversal above the root @@ -67,9 +68,9 @@ def __post_init__(self) -> None: if not isinstance(value, int) or value <= 0: raise ValueError(f'{name} must be a positive integer, got {value!r}') - def get_toolset(self) -> AgentToolset[Any]: + def get_toolset(self) -> AgentToolset[AgentDepsT]: """Build and return the filesystem toolset.""" - return FileSystemToolset( + return FileSystemToolset[AgentDepsT]( root_dir=Path(self.root_dir), allowed_patterns=self.allowed_patterns, denied_patterns=self.denied_patterns, diff --git a/pydantic_ai_harness/filesystem/_toolset.py b/pydantic_ai_harness/filesystem/_toolset.py index a01fb0e..94d3ce4 100644 --- a/pydantic_ai_harness/filesystem/_toolset.py +++ b/pydantic_ai_harness/filesystem/_toolset.py @@ -3,15 +3,41 @@ from __future__ import annotations import fnmatch +import functools import hashlib import os import re -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from pathlib import Path -from typing import Any +from typing import Concatenate, ParamSpec +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.tools import AgentDepsT from pydantic_ai.toolsets import FunctionToolset +_P = ParamSpec('_P') + +# Errors that mean "the model asked for something the tool couldn't do" — a +# missing file, a denied path, a stale edit. pyai only feeds `ModelRetry` back +# to the model; any other exception aborts the whole run. `_recoverable` +# converts these so the agent can correct itself and continue. +_RECOVERABLE_ERRORS = (PermissionError, FileNotFoundError, NotADirectoryError, IsADirectoryError, ValueError) + + +def _recoverable( + fn: Callable[Concatenate[FileSystemToolset, _P], Awaitable[str]], +) -> Callable[Concatenate[FileSystemToolset, _P], Awaitable[str]]: + """Surface model-correctable tool errors as `ModelRetry`.""" + + @functools.wraps(fn) + async def wrapper(self: FileSystemToolset, *args: _P.args, **kwargs: _P.kwargs) -> str: + try: + return await fn(self, *args, **kwargs) + except _RECOVERABLE_ERRORS as e: + raise ModelRetry(str(e)) from e + + return wrapper + def _format_lines(lines: Sequence[str], offset: int, limit: int) -> str: """Format pre-split lines with line numbers and continuation hint.""" @@ -47,7 +73,7 @@ def _content_hash(content: str) -> str: return hashlib.sha256(content.encode('utf-8')).hexdigest()[:12] -class FileSystemToolset(FunctionToolset[Any]): +class FileSystemToolset(FunctionToolset[AgentDepsT]): """Toolset providing filesystem operations scoped to a root directory. Security model: @@ -88,9 +114,22 @@ def __init__( self.add_function(self.create_directory, name='create_directory') self.add_function(self.file_info, name='file_info') + def _matches(self, path: str, pattern: str) -> bool: + """Glob-match a relative path, treating a leading `**/` as 'any directory, including the root'. + + `fnmatch` has no recursive `**`, so a bare `**/secrets*` would miss a + root-level `secrets.yaml` — there's no leading directory to match. + Retrying with the `**/` prefix stripped covers the zero-directory case. + """ + if fnmatch.fnmatch(path, pattern): + return True + if pattern.startswith('**/'): + return fnmatch.fnmatch(path, pattern[3:]) + return False + def _first_matching_pattern(self, path: str, patterns: list[str]) -> str | None: """Return the first pattern that matches path, or None.""" - return next((p for p in patterns if fnmatch.fnmatch(path, p)), None) + return next((p for p in patterns if self._matches(path, p)), None) def _resolve_path(self, path: str) -> Path: """Resolve path relative to root, rejecting traversal. @@ -126,7 +165,7 @@ def _check_access(self, path: str, *, write: bool = False, check_allowed: bool = raise PermissionError(f'Path {path!r} is denied by pattern {matched!r}.') if check_allowed and self._allowed_patterns: - if not any(fnmatch.fnmatch(path, p) for p in self._allowed_patterns): + if not any(self._matches(path, p) for p in self._allowed_patterns): raise PermissionError(f'Path {path!r} does not match any allowed pattern.') def _is_accessible(self, path: str, *, write: bool = False) -> bool: @@ -142,15 +181,27 @@ def _is_accessible(self, path: str, *, write: bool = False) -> bool: if self._denied_patterns: if self._first_matching_pattern(path, self._denied_patterns) is not None: return False - if self._allowed_patterns and not any(fnmatch.fnmatch(path, p) for p in self._allowed_patterns): + if self._allowed_patterns and not any(self._matches(path, p) for p in self._allowed_patterns): return False return True + def _relative_to_root(self, resolved: Path) -> str: + """Canonical path of a resolved location relative to the real root.""" + return str(resolved.relative_to(self._real_root)) + def _safe_resolve(self, path: str, *, write: bool = False, check_allowed: bool = True) -> Path: - """Resolve and access-check a path in one step.""" - self._check_access(path, write=write, check_allowed=check_allowed) - return self._resolve_path(path) + """Resolve and access-check a path in one step. + + Resolution happens first so the access check matches patterns against + the canonical path relative to the root, collapsing `.`/`..`/`//` + segments that would otherwise slip past a literal pattern (e.g. + `config/./secret.txt` evading a `config/secret.txt` deny rule). + """ + resolved = self._resolve_path(path) + self._check_access(self._relative_to_root(resolved), write=write, check_allowed=check_allowed) + return resolved + @_recoverable async def read_file(self, path: str, *, offset: int = 0, limit: int | None = None) -> str: """Read a text file with line numbers. @@ -182,6 +233,7 @@ async def read_file(self, path: str, *, offset: int = 0, limit: int | None = Non header = f'[{path} | {len(lines)} lines | hash:{content_hash}]\n' return header + _format_lines(lines, offset, limit) + @_recoverable async def write_file(self, path: str, content: str, *, expected_hash: str | None = None) -> str: """Create or overwrite a file with conflict detection. @@ -214,6 +266,7 @@ async def write_file(self, path: str, content: str, *, expected_hash: str | None lines = len(content.splitlines()) return f'Wrote {len(content)} chars ({lines} lines) to {path}. [hash:{new_hash}]' + @_recoverable async def edit_file(self, path: str, old_text: str, new_text: str, *, expected_hash: str | None = None) -> str: """Edit a file by exact string replacement with conflict detection. @@ -257,6 +310,7 @@ async def edit_file(self, path: str, old_text: str, new_text: str, *, expected_h new_hash = _content_hash(new_content) return f'Edited {path}. [hash:{new_hash}]' + @_recoverable async def list_directory(self, path: str = '.') -> str: """List the contents of a directory. @@ -299,6 +353,7 @@ async def list_directory(self, path: str = '.') -> str: entries.append(f'{rel} ({size} bytes)') return '\n'.join(entries) if entries else '(empty directory)' + @_recoverable async def search_files(self, pattern: str, *, path: str = '.', include_glob: str | None = None) -> str: """Search file contents using a regular expression. @@ -359,6 +414,7 @@ async def search_files(self, pattern: str, *, path: str = '.', include_glob: str return '\n'.join(results) if results else 'No matches found.' + @_recoverable async def find_files(self, pattern: str, *, path: str = '.') -> str: """Find files by glob pattern (name matching, not content search). @@ -398,6 +454,7 @@ async def find_files(self, pattern: str, *, path: str = '.') -> str: return '\n'.join(matches) if matches else 'No matches found.' + @_recoverable async def create_directory(self, path: str) -> str: """Create a directory and any missing parents. @@ -411,6 +468,7 @@ async def create_directory(self, path: str) -> str: resolved.mkdir(parents=True, exist_ok=True) return f'Created directory: {path}' + @_recoverable async def file_info(self, path: str) -> str: """Get metadata about a file or directory. diff --git a/pydantic_ai_harness/shell/_capability.py b/pydantic_ai_harness/shell/_capability.py index 143c5b9..7fa7730 100644 --- a/pydantic_ai_harness/shell/_capability.py +++ b/pydantic_ai_harness/shell/_capability.py @@ -5,9 +5,9 @@ from collections.abc import Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import Any from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.tools import AgentDepsT from pydantic_ai.toolsets import AgentToolset from pydantic_ai_harness.shell._toolset import ShellToolset @@ -27,7 +27,7 @@ @dataclass -class Shell(AbstractCapability[Any]): +class Shell(AbstractCapability[AgentDepsT]): """Shell command execution for agents. Commands execute in a subprocess rooted at `cwd`. Use `allowed_commands` @@ -62,9 +62,9 @@ class Shell(AbstractCapability[Any]): allow_interactive: bool = False """If True, allow interactive commands (vi, nano, ssh, etc.). Blocked by default.""" - def get_toolset(self) -> AgentToolset[Any] | None: + def get_toolset(self) -> AgentToolset[AgentDepsT]: """Build and return the shell toolset.""" - return ShellToolset( + return ShellToolset[AgentDepsT]( cwd=Path(self.cwd), allowed_commands=self.allowed_commands, denied_commands=self.denied_commands, diff --git a/pydantic_ai_harness/shell/_toolset.py b/pydantic_ai_harness/shell/_toolset.py index afcae12..963e1f4 100644 --- a/pydantic_ai_harness/shell/_toolset.py +++ b/pydantic_ai_harness/shell/_toolset.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import os import re import shlex @@ -9,18 +10,43 @@ import subprocess import tempfile import uuid -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from pathlib import Path -from typing import Any +from typing import Any, Concatenate, ParamSpec import anyio import anyio.abc -from pydantic_ai.toolsets import FunctionToolset +from pydantic_ai import RunContext +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.tools import AgentDepsT +from pydantic_ai.toolsets import AbstractToolset, FunctionToolset -_PWD_SENTINEL = '__HARNESS_PWD__' _IO_DRAIN_TIMEOUT: float = 2.0 _KILL_GRACE_PERIOD: float = 2.0 +_P = ParamSpec('_P') + + +def _recoverable( + fn: Callable[Concatenate[ShellToolset, _P], Awaitable[str]], +) -> Callable[Concatenate[ShellToolset, _P], Awaitable[str]]: + """Convert model-correctable errors into `ModelRetry`. + + pyai only feeds `ModelRetry` back to the model as a retry prompt; any other + exception propagates and aborts the whole run. A denied command is something + the model can recover from (pick an allowed one), so surface it as a retry + instead of crashing the agent. + """ + + @functools.wraps(fn) + async def wrapper(self: ShellToolset, *args: _P.args, **kwargs: _P.kwargs) -> str: + try: + return await fn(self, *args, **kwargs) + except PermissionError as e: + raise ModelRetry(str(e)) from e + + return wrapper + def _is_interactive_command(command: str) -> bool: """Detect commands that typically require interactive input.""" @@ -55,7 +81,7 @@ def __init__( self.exit_code: int | None = None -class ShellToolset(FunctionToolset[Any]): +class ShellToolset(FunctionToolset[AgentDepsT]): """Gives an agent the ability to execute shell commands. Supports synchronous execution (run_command) and background processes @@ -79,6 +105,9 @@ def __init__( ) -> None: super().__init__() self._cwd = cwd.resolve() + # The configured starting directory, never mutated by persist_cwd, so + # `for_run` can hand each run a fresh instance rooted back here. + self._initial_cwd = self._cwd self._allowed_commands = list(allowed_commands) self._denied_commands = list(denied_commands) self._denied_operators = list(denied_operators) @@ -96,6 +125,26 @@ def __init__( self.add_function(self.check_command, name='check_command') self.add_function(self.stop_command, name='stop_command') + async def for_run(self, ctx: RunContext[AgentDepsT]) -> AbstractToolset[AgentDepsT]: + """Return a fresh instance per run so cwd and background processes are isolated. + + `get_toolset` builds one shared instance at agent construction (see + `AbstractToolset.for_run`, which defaults to returning `self`). This + toolset holds mutable per-run state (`_cwd`, `_background`), so without + an override two concurrent runs would corrupt each other's cwd and kill + each other's background processes. + """ + return ShellToolset( + cwd=self._initial_cwd, + allowed_commands=self._allowed_commands, + denied_commands=self._denied_commands, + denied_operators=self._denied_operators, + default_timeout=self._default_timeout, + max_output_chars=self._max_output_chars, + persist_cwd=self._persist_cwd, + allow_interactive=self._allow_interactive, + ) + async def __aexit__(self, *args: Any) -> None: """Terminate all remaining background processes and clean up temp files.""" for bg in self._background.values(): @@ -150,33 +199,34 @@ def _truncate(self, text: str) -> str: marker = f'[... output truncated, showing last {self._max_output_chars} chars]\n' return marker + text[-self._max_output_chars :] - def _wrap_command_for_cwd(self, command: str) -> str: - """Append pwd sentinel to command for cwd tracking. + def _build_cwd_capture(self, command: str) -> tuple[str, Path | None]: + """Wrap a command to record its final working directory out-of-band. - Commands containing ';' are returned unwrapped because the separator - breaks the '&&' success-gating of the sentinel echo. + `pwd` is written to a private temp file whose random path the agent's + command can't address, so command output can never spoof the tracked + cwd — unlike parsing a sentinel out of stdout, where any command that + prints the sentinel string (or one using `;` to skip success-gating) + could redirect the cwd. Returns the wrapped command plus the temp-file + path, or the command unchanged and `None` when cwd tracking is off. """ - if ';' in command: - return command - return f'{command} && echo {_PWD_SENTINEL}$(pwd)' - - def _extract_cwd_from_output(self, stdout: str) -> tuple[str, Path | None]: - """Extract and strip pwd sentinel from stdout. - - Returns (cleaned_stdout, new_cwd_or_none). - """ - sentinel_idx = stdout.rfind(_PWD_SENTINEL) - if sentinel_idx == -1: - return stdout, None - after_sentinel = stdout[sentinel_idx + len(_PWD_SENTINEL) :] - path_str = after_sentinel.strip().split('\n', maxsplit=1)[0].strip() - cleaned = stdout[:sentinel_idx].rstrip('\n') - if not path_str: - return cleaned, None - new_cwd = Path(path_str) - if new_cwd.is_dir(): - return cleaned, new_cwd - return cleaned, None + if not self._persist_cwd: + return command, None + fd, name = tempfile.mkstemp(prefix='harness_cwd_') + os.close(fd) + wrapped = f'{command}\n__harness_ec=$?\npwd > {shlex.quote(name)}\nexit $__harness_ec' + return wrapped, Path(name) + + def _apply_captured_cwd(self, cwd_file: Path) -> None: + """Update the persistent cwd from the capture file, ignoring junk.""" + try: + recorded = cwd_file.read_text(encoding='utf-8').strip() + except OSError: # pragma: no cover + return + if not recorded: + return + candidate = Path(recorded) + if candidate.is_dir(): + self._cwd = candidate async def _kill_process_group(self, proc: anyio.abc.Process) -> None: """SIGTERM the process group, escalating to SIGKILL after the grace period.""" @@ -227,6 +277,7 @@ async def _drain_stderr() -> None: tg.start_soon(_drain_stdout) tg.start_soon(_drain_stderr) + @_recoverable async def run_command(self, command: str, *, timeout_seconds: float | None = None) -> str: """Execute a shell command and return its output. @@ -240,69 +291,69 @@ async def run_command(self, command: str, *, timeout_seconds: float | None = Non self._check_command(command) timeout = timeout_seconds if timeout_seconds is not None else self._default_timeout - actual_command = self._wrap_command_for_cwd(command) if self._persist_cwd else command - - proc = await anyio.open_process( - actual_command, - cwd=self._cwd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - start_new_session=True, - ) - stdout_chunks: list[bytes] = [] - stderr_chunks: list[bytes] = [] + actual_command, cwd_file = self._build_cwd_capture(command) try: - assert proc.stdout is not None - assert proc.stderr is not None - - async def _read_stdout() -> None: + proc = await anyio.open_process( + actual_command, + cwd=self._cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + stdout_chunks: list[bytes] = [] + stderr_chunks: list[bytes] = [] + try: assert proc.stdout is not None - async for chunk in proc.stdout: - stdout_chunks.append(chunk) - - async def _read_stderr() -> None: assert proc.stderr is not None - async for chunk in proc.stderr: - stderr_chunks.append(chunk) - with anyio.fail_after(timeout): - async with anyio.create_task_group() as tg: - tg.start_soon(_read_stdout) - tg.start_soon(_read_stderr) - await proc.wait() - except TimeoutError: - await self._kill_process_group(proc) - with anyio.CancelScope(shield=True): - await proc.wait() - await self._drain_with_timeout(stdout_chunks, stderr_chunks, proc) - return f'[Command timed out after {timeout}s]' + async def _read_stdout() -> None: + assert proc.stdout is not None + async for chunk in proc.stdout: + stdout_chunks.append(chunk) + + async def _read_stderr() -> None: + assert proc.stderr is not None + async for chunk in proc.stderr: + stderr_chunks.append(chunk) + + with anyio.fail_after(timeout): + async with anyio.create_task_group() as tg: + tg.start_soon(_read_stdout) + tg.start_soon(_read_stderr) + await proc.wait() + except TimeoutError: + await self._kill_process_group(proc) + with anyio.CancelScope(shield=True): + await proc.wait() + await self._drain_with_timeout(stdout_chunks, stderr_chunks, proc) + return f'[Command timed out after {timeout}s]' + finally: + await proc.aclose() + + stdout = b''.join(stdout_chunks).decode('utf-8', errors='replace') + stderr = b''.join(stderr_chunks).decode('utf-8', errors='replace') + + parts: list[str] = [] + if stdout: + parts.append(f'[stdout]\n{stdout}') + if stderr: + parts.append(f'[stderr]\n{stderr}') + output = '\n'.join(parts) if parts else '(no output)' + + output = self._truncate(output) + exit_code = proc.returncode if proc.returncode is not None else 0 + + if cwd_file is not None and exit_code == 0: + self._apply_captured_cwd(cwd_file) + + if exit_code != 0: + return f'{output}\n[exit code: {exit_code}]' + return output finally: - await proc.aclose() - - stdout = b''.join(stdout_chunks).decode('utf-8', errors='replace') - stderr = b''.join(stderr_chunks).decode('utf-8', errors='replace') - - new_cwd: Path | None = None - if self._persist_cwd: - stdout, new_cwd = self._extract_cwd_from_output(stdout) - - parts: list[str] = [] - if stdout: - parts.append(f'[stdout]\n{stdout}') - if stderr: - parts.append(f'[stderr]\n{stderr}') - output = '\n'.join(parts) if parts else '(no output)' - - output = self._truncate(output) - exit_code = proc.returncode if proc.returncode is not None else 0 - - if self._persist_cwd and exit_code == 0 and new_cwd is not None: - self._cwd = new_cwd - - if exit_code != 0: - return f'{output}\n[exit code: {exit_code}]' - return output + if cwd_file is not None: + cwd_file.unlink(missing_ok=True) + @_recoverable async def start_command(self, command: str) -> str: """Start a long-running command in the background (e.g. a server or watcher). diff --git a/tests/filesystem/test_filesystem.py b/tests/filesystem/test_filesystem.py index 7c59a5d..cae0704 100644 --- a/tests/filesystem/test_filesystem.py +++ b/tests/filesystem/test_filesystem.py @@ -6,6 +6,7 @@ import pytest from pydantic_ai import Agent +from pydantic_ai.exceptions import ModelRetry from pydantic_ai.models.test import TestModel from pydantic_ai_harness.filesystem import FileSystem @@ -92,7 +93,7 @@ def fs_root(tmp_path: Path) -> Path: @pytest.fixture -def toolset(fs_root: Path) -> FileSystemToolset: +def toolset(fs_root: Path) -> FileSystemToolset[None]: return FileSystemToolset( root_dir=fs_root, allowed_patterns=[], @@ -105,19 +106,19 @@ def toolset(fs_root: Path) -> FileSystemToolset: class TestPathSecurity: - async def test_traversal_with_dotdot(self, toolset: FileSystemToolset) -> None: + async def test_traversal_with_dotdot(self, toolset: FileSystemToolset[None]) -> None: with pytest.raises(PermissionError, match='resolves outside'): toolset._resolve_path('../../../etc/passwd') - async def test_traversal_absolute_path(self, toolset: FileSystemToolset) -> None: + async def test_traversal_absolute_path(self, toolset: FileSystemToolset[None]) -> None: with pytest.raises(PermissionError, match='resolves outside'): toolset._resolve_path('/etc/passwd') - async def test_traversal_encoded(self, toolset: FileSystemToolset) -> None: + async def test_traversal_encoded(self, toolset: FileSystemToolset[None]) -> None: with pytest.raises(PermissionError, match='resolves outside'): toolset._resolve_path('subdir/../../..') - async def test_symlink_escape(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_symlink_escape(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: """Symlink pointing outside root is rejected.""" target = fs_root.parent / 'symlink-escape-target' target.write_text('escaped!\n') @@ -129,23 +130,23 @@ async def test_symlink_escape(self, toolset: FileSystemToolset, fs_root: Path) - finally: target.unlink(missing_ok=True) - async def test_valid_path_resolves(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_valid_path_resolves(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: result = toolset._resolve_path('hello.txt') assert result == (fs_root / 'hello.txt').resolve() - def test_first_matching_pattern_match(self, toolset: FileSystemToolset) -> None: + def test_first_matching_pattern_match(self, toolset: FileSystemToolset[None]) -> None: result = toolset._first_matching_pattern('secret.key', ['*.txt', '*.key']) assert result == '*.key' - def test_first_matching_pattern_no_match(self, toolset: FileSystemToolset) -> None: + def test_first_matching_pattern_no_match(self, toolset: FileSystemToolset[None]) -> None: result = toolset._first_matching_pattern('readme.md', ['*.txt', '*.key']) assert result is None - def test_first_matching_pattern_empty(self, toolset: FileSystemToolset) -> None: + def test_first_matching_pattern_empty(self, toolset: FileSystemToolset[None]) -> None: result = toolset._first_matching_pattern('anything.py', []) assert result is None - async def test_nested_path_resolves(self, toolset: FileSystemToolset) -> None: + async def test_nested_path_resolves(self, toolset: FileSystemToolset[None]) -> None: result = toolset._resolve_path('subdir/nested.py') assert result.name == 'nested.py' @@ -203,19 +204,19 @@ async def test_allowed_pattern_blocks_non_matching(self, fs_root: Path) -> None: with pytest.raises(PermissionError, match='does not match any allowed'): ts._check_access('data.txt') - async def test_protected_pattern_blocks_write(self, toolset: FileSystemToolset) -> None: + async def test_protected_pattern_blocks_write(self, toolset: FileSystemToolset[None]) -> None: with pytest.raises(PermissionError, match='protected'): toolset._check_access('.git/config', write=True) - async def test_protected_pattern_allows_read(self, toolset: FileSystemToolset) -> None: + async def test_protected_pattern_allows_read(self, toolset: FileSystemToolset[None]) -> None: # Should not raise for read toolset._check_access('.git/config', write=False) - async def test_env_file_protected(self, toolset: FileSystemToolset) -> None: + async def test_env_file_protected(self, toolset: FileSystemToolset[None]) -> None: with pytest.raises(PermissionError, match='protected'): toolset._check_access('.env', write=True) - async def test_write_non_protected_with_patterns_configured(self, toolset: FileSystemToolset) -> None: + async def test_write_non_protected_with_patterns_configured(self, toolset: FileSystemToolset[None]) -> None: # write=True on a path that doesn't match any protected pattern should pass toolset._check_access('hello.txt', write=True) @@ -290,56 +291,56 @@ async def test_is_accessible_allowed_list_excludes(self, fs_root: Path) -> None: class TestReadFile: - async def test_read_basic(self, toolset: FileSystemToolset) -> None: + async def test_read_basic(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.read_file('hello.txt') assert 'Hello, world!' in result assert 'hash:' in result assert '1 lines' in result - async def test_read_with_offset(self, toolset: FileSystemToolset) -> None: + async def test_read_with_offset(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.read_file('multi.txt', offset=2) assert 'line3' in result assert 'line1' not in result - async def test_read_with_limit(self, toolset: FileSystemToolset) -> None: + async def test_read_with_limit(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.read_file('multi.txt', limit=2) assert 'line1' in result assert 'line2' in result assert '... (3 more lines' in result - async def test_read_directory_raises(self, toolset: FileSystemToolset) -> None: - with pytest.raises(FileNotFoundError, match='is a directory'): + async def test_read_directory_raises(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='is a directory'): await toolset.read_file('subdir') - async def test_read_missing_raises(self, toolset: FileSystemToolset) -> None: - with pytest.raises(FileNotFoundError, match='File not found'): + async def test_read_missing_raises(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='File not found'): await toolset.read_file('nonexistent.txt') - async def test_read_binary_file(self, toolset: FileSystemToolset) -> None: + async def test_read_binary_file(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.read_file('binary.bin') assert 'Binary file' in result assert '4 bytes' in result - async def test_read_traversal_blocked(self, toolset: FileSystemToolset) -> None: - with pytest.raises(PermissionError): + async def test_read_traversal_blocked(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry): await toolset.read_file('../../../etc/passwd') class TestWriteFile: - async def test_write_new_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_write_new_file(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: result = await toolset.write_file('new.txt', 'new content\n') assert 'Wrote' in result assert (fs_root / 'new.txt').read_text() == 'new content\n' - async def test_write_nonexistent_parent_raises(self, toolset: FileSystemToolset) -> None: - with pytest.raises(FileNotFoundError, match="Parent directory 'deep/nested' does not exist"): + async def test_write_nonexistent_parent_raises(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match="Parent directory 'deep/nested' does not exist"): await toolset.write_file('deep/nested/file.txt', 'deep\n') - async def test_write_overwrite(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_write_overwrite(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: await toolset.write_file('hello.txt', 'overwritten\n') assert (fs_root / 'hello.txt').read_text() == 'overwritten\n' - async def test_write_conflict_detection(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_write_conflict_detection(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: # Get current hash content = (fs_root / 'hello.txt').read_text() current_hash = _content_hash(content) @@ -348,87 +349,87 @@ async def test_write_conflict_detection(self, toolset: FileSystemToolset, fs_roo await toolset.write_file('hello.txt', 'updated\n', expected_hash=current_hash) assert (fs_root / 'hello.txt').read_text() == 'updated\n' - async def test_write_conflict_rejection(self, toolset: FileSystemToolset, fs_root: Path) -> None: - with pytest.raises(ValueError, match='Conflict'): + async def test_write_conflict_rejection(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: + with pytest.raises(ModelRetry, match='Conflict'): await toolset.write_file('hello.txt', 'bad\n', expected_hash='wrong_hash_x') - async def test_write_protected_blocked(self, toolset: FileSystemToolset) -> None: - with pytest.raises(PermissionError, match='protected'): + async def test_write_protected_blocked(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='protected'): await toolset.write_file('.env', 'HACKED=true\n') - async def test_write_returns_hash(self, toolset: FileSystemToolset) -> None: + async def test_write_returns_hash(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.write_file('hashed.txt', 'content\n') assert 'hash:' in result class TestEditFile: - async def test_edit_basic(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_edit_basic(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Hello, universe!') assert 'Edited' in result assert (fs_root / 'hello.txt').read_text() == 'Hello, universe!\n' - async def test_edit_not_found_text(self, toolset: FileSystemToolset) -> None: - with pytest.raises(ValueError, match='old_text not found'): + async def test_edit_not_found_text(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='old_text not found'): await toolset.edit_file('hello.txt', 'NONEXISTENT', 'replacement') - async def test_edit_ambiguous_match(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_edit_ambiguous_match(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: (fs_root / 'repeat.txt').write_text('foo bar foo\n') - with pytest.raises(ValueError, match='found 2 times'): + with pytest.raises(ModelRetry, match='found 2 times'): await toolset.edit_file('repeat.txt', 'foo', 'baz') - async def test_edit_missing_file(self, toolset: FileSystemToolset) -> None: - with pytest.raises(FileNotFoundError, match='File not found'): + async def test_edit_missing_file(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='File not found'): await toolset.edit_file('ghost.txt', 'x', 'y') - async def test_edit_conflict_detection(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_edit_conflict_detection(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: content = (fs_root / 'hello.txt').read_text() current_hash = _content_hash(content) result = await toolset.edit_file('hello.txt', 'Hello', 'Hi', expected_hash=current_hash) assert 'hash:' in result - async def test_edit_conflict_rejection(self, toolset: FileSystemToolset) -> None: - with pytest.raises(ValueError, match='Conflict'): + async def test_edit_conflict_rejection(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='Conflict'): await toolset.edit_file('hello.txt', 'Hello', 'Hi', expected_hash='stale_hash_') - async def test_edit_protected_blocked(self, toolset: FileSystemToolset) -> None: - with pytest.raises(PermissionError, match='protected'): + async def test_edit_protected_blocked(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='protected'): await toolset.edit_file('.env', 'SECRET', 'HACKED') - async def test_edit_returns_new_hash(self, toolset: FileSystemToolset) -> None: + async def test_edit_returns_new_hash(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Goodbye!') assert 'hash:' in result class TestListDirectory: - async def test_list_root(self, toolset: FileSystemToolset) -> None: + async def test_list_root(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.list_directory('.') assert 'hello.txt' in result assert 'subdir/' in result - async def test_list_subdir(self, toolset: FileSystemToolset) -> None: + async def test_list_subdir(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.list_directory('subdir') assert 'nested.py' in result - async def test_list_not_a_dir(self, toolset: FileSystemToolset) -> None: - with pytest.raises(NotADirectoryError): + async def test_list_not_a_dir(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry): await toolset.list_directory('hello.txt') - async def test_list_skips_hidden(self, toolset: FileSystemToolset) -> None: + async def test_list_skips_hidden(self, toolset: FileSystemToolset[None]) -> None: # Dotfiles/dot-directories are hidden, matching find_files/search_files. result = await toolset.list_directory('.') assert 'hello.txt' in result assert '.hidden' not in result assert '.git' not in result - async def test_list_shows_sizes(self, toolset: FileSystemToolset) -> None: + async def test_list_shows_sizes(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.list_directory('.') assert 'bytes' in result - async def test_list_shows_dir_indicator(self, toolset: FileSystemToolset) -> None: + async def test_list_shows_dir_indicator(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.list_directory('.') assert 'subdir/' in result - async def test_list_empty_directory(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_list_empty_directory(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: (fs_root / 'empty').mkdir() result = await toolset.list_directory('empty') assert result == '(empty directory)' @@ -487,39 +488,39 @@ async def test_list_hides_denied_entries(self, fs_root: Path) -> None: class TestSearchFiles: - async def test_search_basic(self, toolset: FileSystemToolset) -> None: + async def test_search_basic(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.search_files('Hello') assert 'hello.txt:1:Hello, world!' in result - async def test_search_regex(self, toolset: FileSystemToolset) -> None: + async def test_search_regex(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.search_files(r'line\d') assert 'multi.txt' in result - async def test_search_no_matches(self, toolset: FileSystemToolset) -> None: + async def test_search_no_matches(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.search_files('ZZZZNOTHERE') assert result == 'No matches found.' - async def test_search_skips_hidden(self, toolset: FileSystemToolset) -> None: + async def test_search_skips_hidden(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.search_files('secret') assert '.hidden' not in result - async def test_search_skips_binary(self, toolset: FileSystemToolset) -> None: + async def test_search_skips_binary(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.search_files('.') assert 'binary.bin' not in result - async def test_search_invalid_regex(self, toolset: FileSystemToolset) -> None: - with pytest.raises(ValueError, match='Invalid regex'): + async def test_search_invalid_regex(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='Invalid regex'): await toolset.search_files('[invalid') - async def test_search_include_glob(self, toolset: FileSystemToolset) -> None: + async def test_search_include_glob(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.search_files('print', include_glob='*.py') assert 'nested.py' in result - async def test_search_include_glob_excludes(self, toolset: FileSystemToolset) -> None: + async def test_search_include_glob_excludes(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.search_files('Hello', include_glob='*.py') assert result == 'No matches found.' - async def test_search_in_specific_file(self, toolset: FileSystemToolset) -> None: + async def test_search_in_specific_file(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.search_files('line', path='multi.txt') assert 'multi.txt' in result @@ -593,33 +594,33 @@ async def test_search_only_matches_allowed_files(self, fs_root: Path) -> None: class TestFindFiles: - async def test_find_glob(self, toolset: FileSystemToolset) -> None: + async def test_find_glob(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.find_files('*.txt') assert 'hello.txt' in result assert 'multi.txt' in result - async def test_find_recursive(self, toolset: FileSystemToolset) -> None: + async def test_find_recursive(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.find_files('**/*.py') assert 'nested.py' in result - async def test_find_no_matches(self, toolset: FileSystemToolset) -> None: + async def test_find_no_matches(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.find_files('*.xyz') assert result == 'No matches found.' - async def test_find_skips_hidden(self, toolset: FileSystemToolset) -> None: + async def test_find_skips_hidden(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.find_files('*') assert '.hidden' not in result assert '.git' not in result - async def test_find_not_a_dir(self, toolset: FileSystemToolset) -> None: - with pytest.raises(NotADirectoryError): + async def test_find_not_a_dir(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry): await toolset.find_files('*.txt', path='hello.txt') - async def test_find_in_subdir(self, toolset: FileSystemToolset) -> None: + async def test_find_in_subdir(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.find_files('*.py', path='subdir') assert 'nested.py' in result - async def test_find_directories(self, toolset: FileSystemToolset) -> None: + async def test_find_directories(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.find_files('sub*') assert 'subdir/' in result @@ -690,26 +691,26 @@ async def test_find_only_shows_allowed_entries(self, fs_root: Path) -> None: class TestCreateDirectory: - async def test_create_basic(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_create_basic(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: result = await toolset.create_directory('newdir') assert 'Created directory' in result assert (fs_root / 'newdir').is_dir() - async def test_create_nested(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_create_nested(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: await toolset.create_directory('a/b/c') assert (fs_root / 'a' / 'b' / 'c').is_dir() - async def test_create_existing_ok(self, toolset: FileSystemToolset) -> None: + async def test_create_existing_ok(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.create_directory('subdir') assert 'Created directory' in result - async def test_create_protected_blocked(self, toolset: FileSystemToolset) -> None: - with pytest.raises(PermissionError, match='protected'): + async def test_create_protected_blocked(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='protected'): await toolset.create_directory('.git/hooks') class TestFileInfo: - async def test_info_file(self, toolset: FileSystemToolset) -> None: + async def test_info_file(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.file_info('hello.txt') assert 'type: file' in result assert 'size:' in result @@ -717,20 +718,20 @@ async def test_info_file(self, toolset: FileSystemToolset) -> None: assert 'hash:' in result assert 'binary: False' in result - async def test_info_directory(self, toolset: FileSystemToolset) -> None: + async def test_info_directory(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.file_info('subdir') assert 'type: directory' in result - async def test_info_binary(self, toolset: FileSystemToolset) -> None: + async def test_info_binary(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.file_info('binary.bin') assert 'binary: True' in result assert 'lines:' not in result - async def test_info_not_found(self, toolset: FileSystemToolset) -> None: - with pytest.raises(FileNotFoundError, match='Path not found'): + async def test_info_not_found(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='Path not found'): await toolset.file_info('nonexistent') - async def test_info_symlink(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_info_symlink(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: link = fs_root / 'link.txt' link.symlink_to(fs_root / 'hello.txt') result = await toolset.file_info('link.txt') @@ -787,20 +788,20 @@ async def test_content_hash_returns_exactly_12_chars(self) -> None: # Verify it's hex characters assert all(c in '0123456789abcdef' for c in h) - async def test_write_file_with_hash_on_new_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_write_file_with_hash_on_new_file(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: """When a file doesn't exist, expected_hash should be ignored and the write should succeed.""" result = await toolset.write_file('brand_new.txt', 'new content\n', expected_hash='any_hash_val') assert 'Wrote' in result assert (fs_root / 'brand_new.txt').read_text() == 'new content\n' - async def test_edit_file_single_match_succeeds(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_edit_file_single_match_succeeds(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: (fs_root / 'unique.txt').write_text('unique text here\n') result = await toolset.edit_file('unique.txt', 'unique text', 'replaced text') assert 'Edited' in result assert (fs_root / 'unique.txt').read_text() == 'replaced text here\n' - async def test_edit_file_zero_matches_raises(self, toolset: FileSystemToolset) -> None: - with pytest.raises(ValueError, match='old_text not found'): + async def test_edit_file_zero_matches_raises(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='old_text not found'): await toolset.edit_file('hello.txt', 'DEFINITELY NOT IN FILE', 'x') async def test_search_truncation_stops_after_limit(self, fs_root: Path) -> None: @@ -842,45 +843,45 @@ async def test_find_truncation_stops_after_limit(self, fs_root: Path) -> None: assert len(lines) == 4 assert 'truncated at 3 matches' in lines[-1] - async def test_read_file_default_limit_used(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_read_file_default_limit_used(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: # Create file with more lines than we'd see with limit=0 (fs_root / 'big.txt').write_text('\n'.join(f'line{i}' for i in range(100)) + '\n') result = await toolset.read_file('big.txt') # All 100 lines should be present since max_read_lines is 2000 assert 'line99' in result - async def test_list_directory_with_files_not_empty(self, toolset: FileSystemToolset) -> None: + async def test_list_directory_with_files_not_empty(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.list_directory('subdir') assert result != '(empty directory)' assert 'nested.py' in result - async def test_search_in_file_returns_only_that_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_search_in_file_returns_only_that_file(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: # Both files contain 'Hello' / 'hello' but searching a specific file should only return from that file (fs_root / 'other.txt').write_text('Hello from other\n') result = await toolset.search_files('Hello', path='hello.txt') assert 'hello.txt' in result assert 'other.txt' not in result - async def test_file_info_non_binary_shows_lines_and_hash(self, toolset: FileSystemToolset) -> None: + async def test_file_info_non_binary_shows_lines_and_hash(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.file_info('hello.txt') assert 'lines: 1' in result assert 'hash:' in result assert 'binary: False' in result - async def test_file_info_binary_no_lines_no_hash(self, toolset: FileSystemToolset) -> None: + async def test_file_info_binary_no_lines_no_hash(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.file_info('binary.bin') assert 'binary: True' in result assert 'lines:' not in result assert 'hash:' not in result - async def test_safe_resolve_passes_write_flag(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_safe_resolve_passes_write_flag(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: # Protected patterns block writes but allow reads (fs_root / '.env.local').write_text('SECRET=x\n') # Read should work (write=False internally) result = await toolset.read_file('.env.local') assert 'SECRET=x' in result # Write should be blocked (write=True internally) - with pytest.raises(PermissionError, match='protected'): + with pytest.raises(ModelRetry, match='protected'): await toolset.write_file('.env.local', 'HACKED\n') async def test_format_lines_join_separator(self) -> None: @@ -897,7 +898,7 @@ async def test_format_lines_no_trailing_newline_preserves_content(self) -> None: assert 'no newline' in result assert result.endswith('\n') - async def test_read_file_hash_is_real_hash(self, toolset: FileSystemToolset) -> None: + async def test_read_file_hash_is_real_hash(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.read_file('hello.txt') # The actual hash should be a hex string, not 'None' assert 'hash:None' not in result @@ -905,7 +906,7 @@ async def test_read_file_hash_is_real_hash(self, toolset: FileSystemToolset) -> expected_hash = _content_hash('Hello, world!\n') assert f'hash:{expected_hash}' in result - async def test_read_file_non_ascii_content(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_read_file_non_ascii_content(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: """With invalid UTF-8 bytes, the tool should not crash — it should use replacement chars.""" # Write raw bytes that are invalid UTF-8 (fs_root / 'broken_utf8.txt').write_bytes(b'hello \xff\xfe world\n') @@ -914,7 +915,7 @@ async def test_read_file_non_ascii_content(self, toolset: FileSystemToolset, fs_ assert 'hello' in result assert 'world' in result - async def test_read_file_default_offset_starts_at_first_line(self, toolset: FileSystemToolset) -> None: + async def test_read_file_default_offset_starts_at_first_line(self, toolset: FileSystemToolset[None]) -> None: """The first line must be included when no offset is specified.""" result = await toolset.read_file('multi.txt') # First line must be present (line1) @@ -922,7 +923,7 @@ async def test_read_file_default_offset_starts_at_first_line(self, toolset: File # Verify line numbering starts at 1 assert ' 0\t' not in result - async def test_toolset_tool_names(self, toolset: FileSystemToolset) -> None: + async def test_toolset_tool_names(self, toolset: FileSystemToolset[None]) -> None: """Verify tools are registered with correct names.""" tool_names = set(toolset.tools.keys()) assert 'read_file' in tool_names @@ -934,7 +935,7 @@ async def test_toolset_tool_names(self, toolset: FileSystemToolset) -> None: assert 'create_directory' in tool_names assert 'file_info' in tool_names - async def test_write_file_output_format(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_write_file_output_format(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: result = await toolset.write_file('fmt.txt', 'ab\ncd\n') # Verify specific format: chars, lines, path, hash assert 'Wrote 6 chars (2 lines) to fmt.txt.' in result @@ -942,7 +943,7 @@ async def test_write_file_output_format(self, toolset: FileSystemToolset, fs_roo # Verify hash is a real hex hash not None assert 'hash:None' not in result - async def test_edit_file_output_format(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_edit_file_output_format(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Hi') assert result.startswith('Edited hello.txt.') assert 'hash:' in result @@ -955,56 +956,58 @@ def test_format_lines_no_double_trailing_newline(self) -> None: # Exact match: no trailing double newline assert result == ' 1\thello\n' - def test_safe_resolve_write_default_is_false(self, toolset: FileSystemToolset, fs_root: Path) -> None: + def test_safe_resolve_write_default_is_false(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: """Protected files should be readable via _safe_resolve's default (write=False).""" (fs_root / '.env.local').write_text('SECRET=x\n') # _safe_resolve without write= uses default write=False → read is allowed resolved = toolset._safe_resolve('.env.local') assert resolved.name == '.env.local' - # But with write=True, it should raise + # But with write=True, it should raise. `_safe_resolve` is an internal + # helper, so it raises the native PermissionError; the `ModelRetry` + # conversion happens in the public tool methods that wrap it. with pytest.raises(PermissionError, match='protected'): toolset._safe_resolve('.env.local', write=True) - async def test_list_directory_exact_size(self, toolset: FileSystemToolset) -> None: + async def test_list_directory_exact_size(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.list_directory('.') # hello.txt has 'Hello, world!\n' = 14 bytes assert '14 bytes' in result - async def test_list_directory_no_garbage_separator(self, toolset: FileSystemToolset) -> None: + async def test_list_directory_no_garbage_separator(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.list_directory('.') assert 'XX' not in result - async def test_list_directory_error_message(self, toolset: FileSystemToolset) -> None: - with pytest.raises(NotADirectoryError, match='Not a directory'): + async def test_list_directory_error_message(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='Not a directory'): await toolset.list_directory('hello.txt') - async def test_find_files_error_message(self, toolset: FileSystemToolset) -> None: - with pytest.raises(NotADirectoryError, match='Not a directory'): + async def test_find_files_error_message(self, toolset: FileSystemToolset[None]) -> None: + with pytest.raises(ModelRetry, match='Not a directory'): await toolset.find_files('*.txt', path='hello.txt') - async def test_find_files_no_suffix_on_files(self, toolset: FileSystemToolset) -> None: + async def test_find_files_no_suffix_on_files(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.find_files('*') for line in result.splitlines(): if not line.endswith('/'): assert 'XXXX' not in line - async def test_find_files_no_garbage_separator(self, toolset: FileSystemToolset) -> None: + async def test_find_files_no_garbage_separator(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.find_files('*.txt') assert 'XX' not in result - async def test_search_files_no_garbage_separator(self, toolset: FileSystemToolset) -> None: + async def test_search_files_no_garbage_separator(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.search_files(r'line\d') assert 'XX' not in result - async def test_file_info_exact_size(self, toolset: FileSystemToolset) -> None: + async def test_file_info_exact_size(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.file_info('hello.txt') assert '14 bytes' in result - async def test_file_info_no_garbage_separator(self, toolset: FileSystemToolset) -> None: + async def test_file_info_no_garbage_separator(self, toolset: FileSystemToolset[None]) -> None: result = await toolset.file_info('hello.txt') assert 'XX' not in result - async def test_search_with_invalid_utf8_file(self, toolset: FileSystemToolset, fs_root: Path) -> None: + async def test_search_with_invalid_utf8_file(self, toolset: FileSystemToolset[None], fs_root: Path) -> None: """A file with invalid UTF-8 (but no null bytes = not binary) should be searchable.""" # Write a file with invalid UTF-8 but no null bytes (not detected as binary) (fs_root / 'bad_encoding.txt').write_bytes(b'marker_text \xff\xfe end\n') @@ -1012,14 +1015,14 @@ async def test_search_with_invalid_utf8_file(self, toolset: FileSystemToolset, f # Should find the file even with broken encoding assert 'bad_encoding.txt' in result - async def test_search_binary_skip_does_not_stop_iteration(self, toolset: FileSystemToolset) -> None: + async def test_search_binary_skip_does_not_stop_iteration(self, toolset: FileSystemToolset[None]) -> None: """A binary file must be skipped, but subsequent text files must still be searched.""" # binary.bin exists in the fixture and comes before 'hello.txt' alphabetically result = await toolset.search_files('Hello') # hello.txt must still be found (binary.bin didn't break the loop) assert 'hello.txt' in result - async def test_find_hidden_skip_does_not_stop_iteration(self, toolset: FileSystemToolset) -> None: + async def test_find_hidden_skip_does_not_stop_iteration(self, toolset: FileSystemToolset[None]) -> None: """Hidden files must be skipped, but subsequent visible files must still appear.""" # .hidden comes before hello.txt alphabetically — skipping must not break the loop result = await toolset.find_files('*') @@ -1081,3 +1084,39 @@ async def test_agent_integration(self, tmp_path: Path, anyio_backend: object) -> agent: Agent[None, str] = Agent(model, capabilities=[FileSystem(root_dir=tmp_path)]) result = await agent.run('read test.txt') assert result.output == 'done' + + +class TestPatternCanonicalization: + """Sec#3: patterns match the canonical path, and a leading `**/` also + covers the repository root.""" + + async def test_denied_pattern_not_bypassed_by_dot_segment(self, fs_root: Path) -> None: + (fs_root / 'config').mkdir() + (fs_root / 'config' / 'secret.txt').write_text('token\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=['config/secret.txt'], + protected_patterns=[], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + # A './' segment must not slip the file past its deny rule. + with pytest.raises(ModelRetry, match='denied'): + await ts.read_file('config/./secret.txt') + + async def test_root_level_secrets_hidden_from_search(self, fs_root: Path) -> None: + (fs_root / 'secrets.yaml').write_text('api: PRIVATE KEY material\n') + ts = FileSystemToolset( + root_dir=fs_root, + allowed_patterns=[], + denied_patterns=[], + protected_patterns=['**/secrets*'], + max_read_lines=2000, + max_search_results=1000, + max_find_results=1000, + ) + # `**/secrets*` must protect a root-level secrets file, not just nested ones. + result = await ts.search_files('PRIVATE KEY') + assert 'secrets.yaml' not in result diff --git a/tests/shell/test_shell.py b/tests/shell/test_shell.py index 12fcdf2..785d0e7 100644 --- a/tests/shell/test_shell.py +++ b/tests/shell/test_shell.py @@ -3,23 +3,37 @@ from __future__ import annotations import os +import shlex import sys from pathlib import Path from unittest.mock import MagicMock, patch import anyio import pytest -from pydantic_ai import Agent +from pydantic_ai import Agent, RunContext +from pydantic_ai.exceptions import ModelRetry from pydantic_ai.models.test import TestModel +from pydantic_ai.usage import RunUsage from pydantic_ai_harness.shell import Shell from pydantic_ai_harness.shell._toolset import ( - _PWD_SENTINEL, ShellToolset, _is_interactive_command, ) +def _run_context() -> RunContext[None]: + """Minimal `RunContext` for invoking `for_run` directly in tests.""" + return RunContext[None]( + deps=None, + model=TestModel(), + usage=RunUsage(), + prompt=None, + messages=[], + run_step=0, + ) + + def _parse_command_id(result: str) -> str: assert 'ID: ' in result, f'Expected "ID: " in result: {result!r}' return result.split('ID: ')[1].strip() @@ -95,7 +109,7 @@ def shell_dir(tmp_path: Path) -> Path: @pytest.fixture -def toolset(shell_dir: Path) -> ShellToolset: +def toolset(shell_dir: Path) -> ShellToolset[None]: return ShellToolset( cwd=shell_dir, allowed_commands=[], @@ -108,8 +122,22 @@ def toolset(shell_dir: Path) -> ShellToolset: ) +@pytest.fixture +def persist_toolset(shell_dir: Path) -> ShellToolset[None]: + return ShellToolset( + cwd=shell_dir, + allowed_commands=[], + denied_commands=[], + denied_operators=[], + default_timeout=10.0, + max_output_chars=50_000, + persist_cwd=True, + allow_interactive=False, + ) + + class TestCommandValidation: - async def test_denied_command_blocked(self, toolset: ShellToolset) -> None: + async def test_denied_command_blocked(self, toolset: ShellToolset[None]) -> None: with pytest.raises(PermissionError, match="'rm' is denied"): toolset._check_command('rm -rf /') @@ -154,7 +182,7 @@ async def test_both_allow_and_deny_raises(self, shell_dir: Path) -> None: allow_interactive=False, ) - async def test_interactive_blocked_by_default(self, toolset: ShellToolset) -> None: + async def test_interactive_blocked_by_default(self, toolset: ShellToolset[None]) -> None: with pytest.raises(PermissionError, match='Interactive commands'): toolset._check_command('vim file.txt') @@ -198,10 +226,10 @@ async def test_denied_operator_passes_when_not_present(self, shell_dir: Path) -> ) ts._check_command('echo hello') - async def test_unparseable_command_allowed(self, toolset: ShellToolset) -> None: + async def test_unparseable_command_allowed(self, toolset: ShellToolset[None]) -> None: toolset._check_command("echo 'unterminated") - async def test_empty_command_allowed(self, toolset: ShellToolset) -> None: + async def test_empty_command_allowed(self, toolset: ShellToolset[None]) -> None: toolset._check_command('') async def test_denied_operator_substring_match(self, shell_dir: Path) -> None: @@ -244,7 +272,7 @@ async def test_empty_tokens(self, shell_dir: Path) -> None: ) ts._check_command('') - def test_first_denied_operator_match(self, toolset: ShellToolset) -> None: + def test_first_denied_operator_match(self, toolset: ShellToolset[None]) -> None: ts = ShellToolset( cwd=Path('/tmp'), allowed_commands=[], @@ -257,7 +285,7 @@ def test_first_denied_operator_match(self, toolset: ShellToolset) -> None: ) assert ts._first_denied_operator('echo hi | cat') == '|' - def test_first_denied_operator_no_match(self, toolset: ShellToolset) -> None: + def test_first_denied_operator_no_match(self, toolset: ShellToolset[None]) -> None: ts = ShellToolset( cwd=Path('/tmp'), allowed_commands=[], @@ -270,12 +298,12 @@ def test_first_denied_operator_no_match(self, toolset: ShellToolset) -> None: ) assert ts._first_denied_operator('echo hello') is None - def test_first_denied_operator_empty_list(self, toolset: ShellToolset) -> None: + def test_first_denied_operator_empty_list(self, toolset: ShellToolset[None]) -> None: assert toolset._first_denied_operator('echo hi | cat') is None class TestTruncation: - def test_within_limit(self, toolset: ShellToolset) -> None: + def test_within_limit(self, toolset: ShellToolset[None]) -> None: assert toolset._truncate('short') == 'short' def test_at_limit(self, shell_dir: Path) -> None: @@ -370,86 +398,114 @@ def test_truncation_marker_wording(self, shell_dir: Path) -> None: assert 'output truncated, showing last 10 chars' in result -class TestCwdSentinel: - def test_wrap_command_appends_sentinel(self, toolset: ShellToolset) -> None: - result = toolset._wrap_command_for_cwd('echo hello') - assert _PWD_SENTINEL in result - assert result == f'echo hello && echo {_PWD_SENTINEL}$(pwd)' - - def test_extract_cwd_no_sentinel(self, toolset: ShellToolset) -> None: - cleaned, cwd = toolset._extract_cwd_from_output('just some output') - assert cleaned == 'just some output' - assert cwd is None - - def test_extract_cwd_with_valid_path(self, toolset: ShellToolset, shell_dir: Path) -> None: - stdout = f'some output\n{_PWD_SENTINEL}{shell_dir}\n' - cleaned, cwd = toolset._extract_cwd_from_output(stdout) - assert 'some output' in cleaned - assert _PWD_SENTINEL not in cleaned - assert cwd == shell_dir - - def test_extract_cwd_invalid_path(self, toolset: ShellToolset) -> None: - stdout = f'output\n{_PWD_SENTINEL}/nonexistent_dir_xyz_999\n' - cleaned, cwd = toolset._extract_cwd_from_output(stdout) - assert _PWD_SENTINEL not in cleaned - assert cwd is None - - def test_extract_cwd_empty_path(self, toolset: ShellToolset) -> None: - stdout = f'output\n{_PWD_SENTINEL}\n' - _, cwd = toolset._extract_cwd_from_output(stdout) - assert cwd is None - - def test_extract_cwd_strips_sentinel_from_output(self, toolset: ShellToolset, shell_dir: Path) -> None: - """Sentinel line should never appear in output shown to model.""" - stdout = f'line1\nline2\n{_PWD_SENTINEL}{shell_dir}\n' - cleaned, _ = toolset._extract_cwd_from_output(stdout) - assert _PWD_SENTINEL not in cleaned - assert 'line1' in cleaned - assert 'line2' in cleaned - - def test_extract_cwd_uses_rfind(self, toolset: ShellToolset, shell_dir: Path) -> None: - """If sentinel appears multiple times, use the LAST one (rfind).""" - stdout = f'{_PWD_SENTINEL}/fake\nmore output\n{_PWD_SENTINEL}{shell_dir}\n' - _, cwd = toolset._extract_cwd_from_output(stdout) - assert cwd == shell_dir - - def test_extract_cwd_cleaned_rstrip(self, toolset: ShellToolset, shell_dir: Path) -> None: - stdout = f'content\n\n{_PWD_SENTINEL}{shell_dir}\n' - cleaned, _ = toolset._extract_cwd_from_output(stdout) - assert not cleaned.endswith('\n') - assert 'content' in cleaned - - def test_extract_cwd_split_maxsplit(self, toolset: ShellToolset, shell_dir: Path) -> None: - stdout = f'{_PWD_SENTINEL}{shell_dir}\nextra_line\n' - _, cwd = toolset._extract_cwd_from_output(stdout) - assert cwd == shell_dir +class TestCwdCapture: + """The persistent-cwd mechanism records `pwd` out-of-band via a private temp + file, so command output can never spoof the tracked directory.""" + + def test_capture_disabled_returns_command_unchanged(self, toolset: ShellToolset[None]) -> None: + wrapped, cwd_file = toolset._build_cwd_capture('echo hi') + assert wrapped == 'echo hi' + assert cwd_file is None + + def test_capture_records_pwd_out_of_band(self, persist_toolset: ShellToolset[None]) -> None: + wrapped, cwd_file = persist_toolset._build_cwd_capture('echo hi') + assert cwd_file is not None + try: + # pwd is redirected to the private temp file, never echoed to stdout + assert f'pwd > {shlex.quote(str(cwd_file))}' in wrapped + assert wrapped.startswith('echo hi') + finally: + cwd_file.unlink(missing_ok=True) + + def test_apply_valid_dir_updates_cwd( + self, persist_toolset: ShellToolset[None], shell_dir: Path, tmp_path: Path + ) -> None: + capture = tmp_path / 'cwd' + capture.write_text(f'{shell_dir / "subdir"}\n') + persist_toolset._apply_captured_cwd(capture) + assert persist_toolset._cwd == shell_dir / 'subdir' + + def test_apply_empty_file_keeps_cwd(self, persist_toolset: ShellToolset[None], tmp_path: Path) -> None: + original = persist_toolset._cwd + capture = tmp_path / 'cwd' + capture.write_text('') + persist_toolset._apply_captured_cwd(capture) + assert persist_toolset._cwd == original + + def test_apply_non_dir_keeps_cwd(self, persist_toolset: ShellToolset[None], tmp_path: Path) -> None: + original = persist_toolset._cwd + capture = tmp_path / 'cwd' + capture.write_text(str(tmp_path / 'does_not_exist')) + persist_toolset._apply_captured_cwd(capture) + assert persist_toolset._cwd == original + + +class TestForRunIsolation: + """B3: `get_toolset` builds one shared instance at agent construction, so + `for_run` must hand each run a fresh copy — otherwise concurrent runs share + `_cwd`/`_background` and corrupt each other.""" + + async def test_for_run_returns_fresh_instance(self, persist_toolset: ShellToolset[None]) -> None: + run1 = await persist_toolset.for_run(_run_context()) + run2 = await persist_toolset.for_run(_run_context()) + assert run1 is not persist_toolset + assert run2 is not run1 + + async def test_persist_cwd_isolated_across_runs(self, persist_toolset: ShellToolset[None], shell_dir: Path) -> None: + run1 = await persist_toolset.for_run(_run_context()) + assert isinstance(run1, ShellToolset) + await run1.run_command('cd subdir') + assert run1._cwd == shell_dir / 'subdir' + # A second run must start back at the configured root, not inherit run1's cd. + run2 = await persist_toolset.for_run(_run_context()) + assert isinstance(run2, ShellToolset) + assert run2._cwd == shell_dir + + +class TestPersistCwdHardening: + """B4: regression tests for the old stdout-sentinel footguns — a command's + output spoofing the cwd, and `;` silently disabling tracking.""" + + async def test_cd_persists_even_with_semicolon(self, persist_toolset: ShellToolset[None]) -> None: + # The old mechanism skipped tracking whenever ';' appeared, silently + # dropping a real `cd`. The out-of-band capture records it regardless. + await persist_toolset.run_command('cd subdir ; true') + result = await persist_toolset.run_command('pwd') + assert 'subdir' in result + + async def test_output_cannot_spoof_cwd(self, persist_toolset: ShellToolset[None], shell_dir: Path) -> None: + # The old mechanism parsed cwd from stdout, so a command printing the + # sentinel string could redirect the tracked cwd with no real cd. + spoof = f'true ; echo __HARNESS_PWD__{shell_dir / "subdir"}' + await persist_toolset.run_command(spoof) + assert persist_toolset._cwd == shell_dir class TestRunCommand: - async def test_basic_echo(self, toolset: ShellToolset) -> None: + async def test_basic_echo(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command('echo hello') assert '[stdout]' in result assert 'hello' in result - async def test_stderr_output(self, toolset: ShellToolset) -> None: + async def test_stderr_output(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command('echo error >&2') assert '[stderr]' in result assert 'error' in result - async def test_mixed_output(self, toolset: ShellToolset) -> None: + async def test_mixed_output(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command('echo out && echo err >&2') assert '[stdout]' in result assert '[stderr]' in result - async def test_exit_code_reported(self, toolset: ShellToolset) -> None: + async def test_exit_code_reported(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command('exit 42') assert '[exit code: 42]' in result - async def test_exit_code_zero_not_shown(self, toolset: ShellToolset) -> None: + async def test_exit_code_zero_not_shown(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command('echo ok') assert 'exit code' not in result - async def test_no_output(self, toolset: ShellToolset) -> None: + async def test_no_output(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command('true') assert result == '(no output)' @@ -497,15 +553,17 @@ async def test_persist_cwd_only_on_success(self, shell_dir: Path) -> None: await ts.run_command('cd nonexistent_dir_xyz && false') assert ts._cwd == original - async def test_denied_command_in_run(self, toolset: ShellToolset) -> None: - with pytest.raises(PermissionError, match="'rm' is denied"): + async def test_denied_command_in_run(self, toolset: ShellToolset[None]) -> None: + # B2: a denied command is model-correctable, so it surfaces as ModelRetry + # (which pyai feeds back to the model) rather than aborting the run. + with pytest.raises(ModelRetry, match="'rm' is denied"): await toolset.run_command('rm -rf /') - async def test_cwd_used(self, toolset: ShellToolset, shell_dir: Path) -> None: + async def test_cwd_used(self, toolset: ShellToolset[None], shell_dir: Path) -> None: result = await toolset.run_command('cat test.txt') assert 'hello' in result - async def test_multiline_output(self, toolset: ShellToolset) -> None: + async def test_multiline_output(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command(f'{sys.executable} -c "print(\'a\\nb\\nc\\n\')"') assert '[stdout]' in result @@ -552,27 +610,27 @@ async def test_persist_cwd_disabled_no_update(self, shell_dir: Path) -> None: await ts.run_command('cd subdir') assert ts._cwd == original - async def test_nonzero_exit_shows_code(self, toolset: ShellToolset) -> None: + async def test_nonzero_exit_shows_code(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command('exit 1') assert '[exit code: 1]' in result - async def test_stdout_stderr_separated_by_newline(self, toolset: ShellToolset) -> None: + async def test_stdout_stderr_separated_by_newline(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command('echo out && echo err >&2') assert '[stdout]\nout\n\n[stderr]\nerr' in result - async def test_non_ascii_stdout(self, toolset: ShellToolset) -> None: + async def test_non_ascii_stdout(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command( f'{sys.executable} -c "import sys; sys.stdout.buffer.write(b\'hello \\xff\\xfe world\\n\')"' ) assert 'hello' in result - async def test_non_ascii_stderr(self, toolset: ShellToolset) -> None: + async def test_non_ascii_stderr(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command( f'{sys.executable} -c "import sys; sys.stderr.buffer.write(b\'err \\xff\\xfe msg\\n\')"' ) assert 'err' in result - async def test_stdout_chunk_join(self, toolset: ShellToolset) -> None: + async def test_stdout_chunk_join(self, toolset: ShellToolset[None]) -> None: result = await toolset.run_command(f"{sys.executable} -c \"print('A' * 100 + 'B' * 100)\"") assert 'A' * 100 + 'B' * 100 in result @@ -631,22 +689,6 @@ async def test_stderr_chunks_joined_cleanly(self, shell_dir: Path) -> None: result = await ts.run_command("printf '%0500d\\n' $(seq 1 100) >&2") assert 'XXXX' not in result - async def test_persist_cwd_sentinel_stripped_from_output(self, shell_dir: Path) -> None: - """The pwd sentinel should never appear in output shown to user.""" - ts = ShellToolset( - cwd=shell_dir, - allowed_commands=[], - denied_commands=[], - denied_operators=[], - default_timeout=10.0, - max_output_chars=50_000, - persist_cwd=True, - allow_interactive=False, - ) - result = await ts.run_command('echo visible') - assert _PWD_SENTINEL not in result - assert 'visible' in result - async def test_persist_cwd_updates_after_cd(self, shell_dir: Path) -> None: """CWD should update to the actual directory after a successful cd.""" ts = ShellToolset( @@ -745,11 +787,11 @@ async def test_start_command_returns_id(self, shell_dir: Path) -> None: command_id = _parse_command_id(result) await ts.stop_command(command_id) - async def test_check_unknown_id(self, toolset: ShellToolset) -> None: + async def test_check_unknown_id(self, toolset: ShellToolset[None]) -> None: result = await toolset.check_command('nonexistent_id') assert 'unknown command ID' in result - async def test_stop_unknown_id(self, toolset: ShellToolset) -> None: + async def test_stop_unknown_id(self, toolset: ShellToolset[None]) -> None: result = await toolset.stop_command('nonexistent_id') assert 'unknown command ID' in result @@ -825,7 +867,7 @@ async def test_start_denied_command_raises(self, shell_dir: Path) -> None: persist_cwd=False, allow_interactive=False, ) - with pytest.raises(PermissionError, match="'rm' is denied"): + with pytest.raises(ModelRetry, match="'rm' is denied"): await ts.start_command('rm -rf /') async def test_stop_captures_stderr(self, shell_dir: Path) -> None: @@ -1028,7 +1070,7 @@ async def test_aexit_cleans_already_finished_process(self, shell_dir: Path) -> N class TestEdgeCases: - async def test_toolset_tool_names(self, toolset: ShellToolset) -> None: + async def test_toolset_tool_names(self, toolset: ShellToolset[None]) -> None: tool_names = list(toolset.tools.keys()) assert 'run_command' in tool_names assert 'start_command' in tool_names @@ -1049,16 +1091,6 @@ async def test_run_command_uses_actual_cwd(self, shell_dir: Path) -> None: result = await ts.run_command('pwd') assert str(shell_dir) in result - def test_wrap_command_uses_correct_sentinel(self, toolset: ShellToolset) -> None: - result = toolset._wrap_command_for_cwd('ls') - assert '__HARNESS_PWD__' in result - assert '$(pwd)' in result - - def test_extract_cwd_rfind_not_find(self, toolset: ShellToolset, shell_dir: Path) -> None: - stdout = f'{_PWD_SENTINEL}/fake\nstuff\n{_PWD_SENTINEL}{shell_dir}\n' - _, cwd = toolset._extract_cwd_from_output(stdout) - assert cwd == shell_dir - async def test_persist_cwd_requires_all_three_conditions(self, shell_dir: Path) -> None: ts = ShellToolset( cwd=shell_dir, @@ -1074,36 +1106,6 @@ async def test_persist_cwd_requires_all_three_conditions(self, shell_dir: Path) await ts.run_command('echo hi') assert ts._cwd.is_dir() - async def test_persist_cwd_false_skips_sentinel(self, shell_dir: Path) -> None: - ts = ShellToolset( - cwd=shell_dir, - allowed_commands=[], - denied_commands=[], - denied_operators=[], - default_timeout=10.0, - max_output_chars=50_000, - persist_cwd=False, - allow_interactive=False, - ) - result = await ts.run_command('echo test') - assert _PWD_SENTINEL not in result - - async def test_persist_cwd_semicolon_skips_sentinel(self, shell_dir: Path) -> None: - ts = ShellToolset( - cwd=shell_dir, - allowed_commands=[], - denied_commands=[], - denied_operators=[], - default_timeout=10.0, - max_output_chars=50_000, - persist_cwd=True, - allow_interactive=False, - ) - original_cwd = ts._cwd - result = await ts.run_command('echo a ; echo b') - assert _PWD_SENTINEL not in result - assert ts._cwd == original_cwd - class TestShellCapability: def test_default_construction(self) -> None: