feat: add FileSystem and Shell capabilities (#260)

* 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

---

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: David Sanchez <64162682+dsfaccini@users.noreply.github.com>
This commit is contained in:
Bill Easton
2026-06-01 20:47:07 -05:00
committed by GitHub
co-authored by Claude David Sanchez
parent ad1a2014ef
commit e1654ccfe2
20 changed files with 4104 additions and 5 deletions
+1
View File
@@ -25,3 +25,4 @@ wheels/
# Hypothesis
.hypothesis/
.vscode/
mutants/
+2 -2
View File
@@ -103,8 +103,8 @@ We studied leading coding agents, agent frameworks, and Claw-style assistants to
|---|---|---|---|---|
| **Tools &&nbsp;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&nbsp;AI](https://pydantic.dev/docs/ai/tools-toolsets/toolsets/#deferred-loading) | |
| | **File system** | Read, write, edit, search files with path traversal prevention | :construction: [PR&nbsp;#177](https://github.com/pydantic/pydantic-ai-harness/pull/177) | [pydantic-ai-backend](https://github.com/vstorm-co/pydantic-ai-backend) (vstorm&#8209;co) |
| | **Shell** | Execute commands with allowlists, denylists, and timeouts | :construction: [PR&nbsp;#177](https://github.com/pydantic/pydantic-ai-harness/pull/177) | [pydantic-ai-backend](https://github.com/vstorm-co/pydantic-ai-backend) (vstorm&#8209;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&#8209;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&#8209;co) |
| | **Repo context injection** | Auto-load CLAUDE.md/AGENTS.md and repo structure | :construction: [PR&nbsp;#175](https://github.com/pydantic/pydantic-ai-harness/pull/175) | [pydantic-deep](https://github.com/vstorm-co/pydantic-deepagents) (vstorm&#8209;co) |
| | **Verification loop** | Run tests after edits, auto-fix failures | :construction: [PR&nbsp;#169](https://github.com/pydantic/pydantic-ai-harness/pull/169) | |
| **Context management** | **Sliding window** | Trim conversation history to stay within token limits | :construction: [PR&nbsp;#191](https://github.com/pydantic/pydantic-ai-harness/pull/191) | [summarization-pydantic-ai](https://github.com/vstorm-co/summarization-pydantic-ai) (vstorm&#8209;co) |
+47
View File
@@ -0,0 +1,47 @@
# Mutation Testing
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`.
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.
```bash
scripts/run-mutmut.sh run --max-children 1
scripts/run-mutmut.sh results
scripts/run-mutmut.sh show <mutant-name>
```
## Interpreting 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:
- **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.
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. The kill rate is unaffected —
the trio tests exercise the same code paths as the asyncio tests.
+12 -2
View File
@@ -1,11 +1,13 @@
"""The batteries for your Pydantic AI agent -- the official capability library."""
"""Pydantic AI capability library."""
from typing import TYPE_CHECKING
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
elif name == 'FileSystem':
from .filesystem import FileSystem
return FileSystem
elif name == 'Shell':
from .shell import Shell
return Shell
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
+136
View File
@@ -0,0 +1,136 @@
# 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 (`.git`, `.env`, `.github`, …) are skipped by
> all three walkers — `list_directory`, `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/)
@@ -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']
@@ -0,0 +1,81 @@
"""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.tools import AgentDepsT
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[AgentDepsT]):
"""File system access scoped to a root directory.
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=list[str])
"""If non-empty, only paths matching at least one glob pattern are accessible."""
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))
"""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 __post_init__(self) -> None:
# 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}')
def get_toolset(self) -> AgentToolset[AgentDepsT]:
"""Build and return the filesystem toolset."""
return FileSystemToolset[AgentDepsT](
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,
)
+507
View File
@@ -0,0 +1,507 @@
"""Filesystem toolset providing sandboxed file operations."""
from __future__ import annotations
import fnmatch
import functools
import hashlib
import os
import re
from collections.abc import Awaitable, Callable, Sequence
from pathlib import Path
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."""
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[AgentDepsT]):
"""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._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)
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')
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 self._matches(path, p)), None)
def _resolve_path(self, path: str) -> Path:
"""Resolve path relative to root, rejecting traversal.
Uses os.path.realpath for symlink resolution before checking containment.
"""
candidate = (self._root / path).resolve()
real = Path(os.path.realpath(candidate))
if not real.is_relative_to(self._real_root):
raise PermissionError(f'Path {path!r} resolves outside the root directory.')
return real
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:
raise PermissionError(f'Path {path!r} is protected (matches {matched!r}).')
if self._denied_patterns:
matched = self._first_matching_pattern(path, self._denied_patterns)
if matched:
raise PermissionError(f'Path {path!r} is denied by pattern {matched!r}.')
if check_allowed and 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:
"""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(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.
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.
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: 2000).
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')
lines = text.splitlines(keepends=True)
content_hash = _content_hash(text)
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.
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.'
)
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())
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.
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}]'
@_recoverable
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.
"""
# 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}')
entries: list[str] = []
for entry in sorted(resolved.iterdir()):
try:
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.
if not self._is_accessible(rel, write=True):
continue
if entry.is_dir():
entries.append(f'{rel}/')
else:
try:
size = entry.stat().st_size
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)'
@_recoverable
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.
"""
# 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:
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
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:
raw = file_path.read_bytes()
except OSError: # pragma: no cover
continue
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.'
@_recoverable
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.
"""
# 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}')
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
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:
matches.append(f'[... truncated at {self._max_find_results} matches]')
break
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.
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}'
@_recoverable
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:
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)}')
return '\n'.join(parts)
+129
View File
@@ -0,0 +1,129 @@
# 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. 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
| 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/)
+6
View File
@@ -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']
+76
View File
@@ -0,0 +1,76 @@
"""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 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
_DEFAULT_DENIED_COMMANDS: list[str] = [
'rm',
'rmdir',
'mkfs',
'dd',
'format',
'shutdown',
'reboot',
'halt',
'poweroff',
'init',
]
@dataclass
class Shell(AbstractCapability[AgentDepsT]):
"""Shell command execution for agents.
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=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=list[str])
"""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[AgentDepsT]:
"""Build and return the shell toolset."""
return ShellToolset[AgentDepsT](
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,
)
+500
View File
@@ -0,0 +1,500 @@
"""Shell toolset — gives agents the ability to run commands."""
from __future__ import annotations
import functools
import os
import re
import shlex
import signal
import subprocess
import tempfile
import uuid
from collections.abc import Awaitable, Callable, Sequence
from pathlib import Path
from typing import Any, Concatenate, ParamSpec
import anyio
import anyio.abc
from pydantic_ai import RunContext
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.tools import AgentDepsT
from pydantic_ai.toolsets import AbstractToolset, FunctionToolset
_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."""
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[AgentDepsT]):
"""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()
# 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)
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')
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():
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 _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.
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 = self._first_denied_operator(command)
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) -> 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
marker = f'[... output truncated, showing last {self._max_output_chars} chars]\n'
return marker + text[-self._max_output_chars :]
def _build_cwd_capture(self, command: str) -> tuple[str, Path | None]:
"""Wrap a command to record its final working directory out-of-band.
`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 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."""
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)
@_recoverable
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: 30).
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, cwd_file = self._build_cwd_capture(command)
try:
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')
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:
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).
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.
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)
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()
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}]')
output_sections: list[str] = []
if stdout:
output_sections.append(f'[stdout]\n{stdout}')
if 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)
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}]')
output_sections: list[str] = []
if stdout:
output_sections.append(f'[stdout]\n{stdout}')
if 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)
+13 -1
View File
@@ -112,7 +112,7 @@ quote-style = 'single'
[tool.pyright]
pythonVersion = '3.10'
typeCheckingMode = 'strict'
exclude = ['template', '.venv']
exclude = ['template', '.venv', 'mutants']
executionEnvironments = [
{ root = 'tests', reportPrivateUsage = false },
]
@@ -143,3 +143,15 @@ 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']
# See docs/mutation-testing.md for full results (89.7% kill rate, 60 equivalent mutants).
+24
View File
@@ -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 <mutant> # 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 "$@"
View File
File diff suppressed because it is too large Load Diff
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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)
Generated
+5
View File
@@ -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]