Files
pydantic-ai-harness/pydantic_ai_harness/shell/README.md
T
e1654ccfe2 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>
2026-06-01 20:47:07 -05:00

4.8 KiB

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.

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. >, >>, `
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

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:

# agent.yaml
model: anthropic:claude-sonnet-4-6
capabilities:
  - Shell:
      cwd: ./workspace
      allowed_commands: ['ls', 'cat', 'rg', 'pytest']
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