Skip to content

Commit a4e5d00

Browse files
authored
feat(workspaces): confine file operations with ConfinedWorkspace (#121)
## Problem A review of vstorm-co/pydantic-deepagents#222 (moving pydantic-deep onto workspaces) found that its CLI and ACP server, now on Pydantic AI's local workspace, no longer kept file tools inside the project. `LocalBackend(root_dir=...)` refused paths outside its root; `LocalWorkspaceBackend` confines nothing. With the CLI's default of approving only `execute`, `write_file` to `~/.zshrc` succeeded unattended. The fix belongs here, where `LocalBackend` was. While testing it, a second bug surfaced: on macOS the console's `grep` found nothing below the search root. ## What changes - **`ConfinedWorkspace`** (exported from the package root and `pydantic_ai_backends.workspaces`): a `WrapperWorkspace` that refuses a file operation whose path leaves the working directory. The path is resolved against the working directory, then through every symlink via `Workspace.realpath`. The refusal is `WorkspacePathError`, a `WorkspaceError` and a `PermissionError`. The console's `glob` and `grep` run `find`/`grep` as commands, which the wrapper leaves alone, so they check their search root against it. `find` doesn't follow symlinks below the root and `grep -r` follows only the ones on its command line, so checking the root is enough. Through the console, a refused call reaches the model as the tool's error. **Commands are not confined**, and the docs say to isolate those with a sandbox. The module sits outside `workspaces/` so the console path doesn't import `httpx`. - **`grep` on macOS:** BSD grep matches `--exclude-dir` against the walked path (`./src`), and `.[!.]*` matched every such path, so with `ignore_hidden` on nothing below the root was found. GNU grep tests a directory's base name, which is why CI never saw it. Now two patterns, `.[!./]*` and `*/.[!.]*`. I checked them against `/usr/bin/grep` (BSD) and GNU grep in `python:3.12-slim`: hidden directories are skipped, and nested and dotted-name directories are searched. Separate commit. ## Verification - `make test`: 1337 passed, 100% coverage. `make lint`, `make typecheck`, `make typecheck-mypy` are clean. - New `tests/test_workspace_confined.py`, on real directories: - every file operation inside works; `..`, absolute paths and symlinks out are refused, and writes outside leave the file untouched; - each of stat/list_dir/make_dir/remove/exists is checked; - commands are not confined; - `glob`/`grep` rooted outside are refused, while an unconfined workspace still searches anywhere and a confined document is searched by walking; - through the console tool, the model reads the refusal. - The grep regression test searches a subdirectory beside `.git` and a hidden `.cache`, so it runs against whichever grep the platform has.
1 parent 051e634 commit a4e5d00

9 files changed

Lines changed: 325 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
gone instead of starting over in an empty one, and a ref naming anything else
2020
is left to another capability.
2121

22+
- **`ConfinedWorkspace`.** Pydantic AI's local workspace confines nothing, so moving from
23+
`LocalBackend(root_dir=...)` to it let the file tools - which usually run without
24+
approval - write anywhere the process can. `ConfinedWorkspace` wraps any workspace and
25+
refuses a file operation whose real path, symlinks followed, leaves its working directory,
26+
with `WorkspacePathError` (a `PermissionError`); the console's `glob` and `grep` check
27+
their search root the same way. Commands are not confined: isolate those with a sandbox.
28+
29+
### Fixed
30+
31+
- **`grep` on macOS searched only the top directory.** BSD grep matches `--exclude-dir`
32+
against the path it walks, `./src`, and the hidden-directory pattern `.[!.]*` matched every
33+
such path, so with `ignore_hidden` (the default) nothing below the search root was found.
34+
Two patterns now cover GNU grep, which tests a directory's base name, and BSD grep.
35+
2236
## [0.2.31] - 2026-10-05
2337

2438
### Added

‎docs/concepts/workspaces.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,29 @@ policy wrapped around it — `ReadOnlyWorkspace`, or a `WrapperWorkspace` of you
111111
applies to every one of them, and on a read-only workspace the write and execute tools are
112112
not offered at all.
113113

114+
## Keeping the file tools in one directory
115+
116+
Pydantic AI's `LocalWorkspace` points a run at a directory but confines nothing: an absolute
117+
path, a `..` or a symlink reaches any file the process can. `ConfinedWorkspace` wraps a
118+
workspace and refuses a file operation whose path - resolved against the working directory,
119+
then through every symlink - leads outside it, with `WorkspacePathError` (a
120+
`PermissionError`). The console's `glob` and `grep` check their search root the same way.
121+
A refused call reaches the model as the tool's error, not as the end of the run.
122+
123+
```python
124+
from pydantic_ai.workspaces import LocalWorkspaceBackend, Workspace
125+
126+
from pydantic_ai_backends.workspaces import ConfinedWorkspace
127+
128+
project = ConfinedWorkspace(Workspace(LocalWorkspaceBackend("./project")))
129+
result = await agent.run(prompt, workspace=project)
130+
```
131+
132+
**Commands are not confined.** A shell reaches whatever its user can, and inspecting a
133+
command line is not a boundary; `ConfinedWorkspace` keeps the file tools - which usually run
134+
without approval - where they were pointed, as `LocalBackend(root_dir=...)` did before 0.2.30.
135+
To isolate commands, give the run a sandbox such as `DockerWorkspace`.
136+
114137
## What was checked
115138

116139
`DockerWorkspace` and `SandboxdWorkspace` pass Pydantic AI's own `WorkspaceBackendSuite`

‎src/pydantic_ai_backends/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
)
3737

3838
if TYPE_CHECKING:
39+
from pydantic_ai_backends._confined import ConfinedWorkspace, WorkspacePathError
3940
from pydantic_ai_backends.backends.docker import BUILTIN_RUNTIMES, DockerSandbox, SessionManager
4041
from pydantic_ai_backends.backends.docker.runtimes import get_runtime
4142
from pydantic_ai_backends.backends.docker.session import SandboxFactory
@@ -105,6 +106,7 @@
105106
)
106107

107108
_LAZY_MODULES: dict[str, tuple[str, ...]] = {
109+
"pydantic_ai_backends._confined": ("ConfinedWorkspace", "WorkspacePathError"),
108110
"pydantic_ai_backends.workspaces": (
109111
"DaytonaWorkspace",
110112
"DaytonaWorkspaceBackend",
@@ -212,6 +214,7 @@
212214
"AskFallback",
213215
"CommandOutcome",
214216
"CommandRunner",
217+
"ConfinedWorkspace",
215218
"ConsoleCapability",
216219
"ConsoleToolset",
217220
"DaytonaWorkspace",
@@ -248,6 +251,7 @@
248251
"ToolText",
249252
"WorkspaceArchive",
250253
"WorkspaceArchiveError",
254+
"WorkspacePathError",
251255
"apply_hashline_edit",
252256
"apply_hashline_edit_with_summary",
253257
"create_console_toolset",
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""A workspace whose file operations stay inside its working directory."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Sequence
6+
7+
from pydantic_ai.workspaces import FileEntry, Workspace, WorkspaceError, WrapperWorkspace
8+
9+
10+
class WorkspacePathError(WorkspaceError, PermissionError):
11+
"""A file operation named a path outside a confined workspace."""
12+
13+
14+
class ConfinedWorkspace(WrapperWorkspace):
15+
"""Keep file operations inside the wrapped workspace's working directory.
16+
17+
A path is checked where it really leads - resolved against the working
18+
directory, then through every symlink - so neither `..` nor a link reaches
19+
past the root. The console's `glob` and `grep` check their search root the
20+
same way.
21+
22+
**Commands are not confined.** A shell reaches any file its user can, and a
23+
string check of a command line is not a boundary; isolate commands with a
24+
sandboxed workspace such as `DockerWorkspace`. This keeps the file tools -
25+
which typically run without approval - where `LocalWorkspace` points them,
26+
as `LocalBackend(root_dir=...)` did before 0.2.30.
27+
28+
Example:
29+
```python
30+
from pydantic_ai.workspaces import LocalWorkspaceBackend, Workspace
31+
32+
from pydantic_ai_backends.workspaces import ConfinedWorkspace
33+
34+
project = ConfinedWorkspace(Workspace(LocalWorkspaceBackend("./project")))
35+
result = await agent.run(prompt, workspace=project)
36+
```
37+
"""
38+
39+
def __init__(self, wrapped: Workspace) -> None:
40+
super().__init__(wrapped)
41+
self._root: str | None = None
42+
43+
async def _confined_root(self) -> str:
44+
if self._root is None:
45+
self._root = await self.realpath(await self.working_dir())
46+
return self._root
47+
48+
async def contains(self, path: str) -> bool:
49+
"""Whether `path`, relative to the working directory or absolute, leads inside it."""
50+
root = await self._confined_root()
51+
target = await self.realpath(await self.resolve(path))
52+
return target == root or target.startswith(root.rstrip("/") + "/")
53+
54+
async def check(self, path: str) -> None:
55+
"""Refuse `path` when it leads outside the working directory.
56+
57+
Raises:
58+
WorkspacePathError: It does.
59+
"""
60+
if not await self.contains(path):
61+
raise WorkspacePathError(
62+
f"{path!r} is outside the workspace ({await self._confined_root()})"
63+
)
64+
65+
async def read_bytes(self, path: str) -> bytes:
66+
await self.check(path)
67+
return await super().read_bytes(path)
68+
69+
async def write_bytes(self, path: str, data: bytes) -> None:
70+
await self.check(path)
71+
await super().write_bytes(path, data)
72+
73+
async def stat(self, path: str) -> FileEntry:
74+
await self.check(path)
75+
return await super().stat(path)
76+
77+
async def list_dir(self, path: str) -> Sequence[FileEntry]:
78+
await self.check(path)
79+
return await super().list_dir(path)
80+
81+
async def make_dir(self, path: str) -> None:
82+
await self.check(path)
83+
await super().make_dir(path)
84+
85+
async def remove(self, path: str) -> None:
86+
await self.check(path)
87+
await super().remove(path)
88+
89+
async def exists(self, path: str) -> bool:
90+
await self.check(path)
91+
return await super().exists(path)
92+
93+
94+
def confinement(workspace: Workspace) -> ConfinedWorkspace | None:
95+
"""The `ConfinedWorkspace` among `workspace`'s layers, if there is one."""
96+
layer: object = workspace
97+
while isinstance(layer, Workspace):
98+
if isinstance(layer, ConfinedWorkspace):
99+
return layer
100+
layer = layer._backend # pyright: ignore[reportPrivateUsage]
101+
return None

‎src/pydantic_ai_backends/toolsets/_shell.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,16 @@ def grep_command(
8787
# file, and `parse_grep` then reads no match at all.
8888
options = ["-rnH"]
8989
if ignore_hidden:
90-
# Directories only, and `.[!.]*` rather than `.*`. BSD grep matches both
91-
# excludes against the path as it walks it - `./notes.txt` - so `.*`
92-
# excluded the starting directory and a file exclude excluded every
93-
# file, and a search of the working directory found nothing on macOS.
94-
# Hidden files are dropped by `hidden_match` instead. Quoted, or the
95-
# shell expands the pattern against the working directory.
96-
options.append(f"--exclude-dir={shlex.quote('.[!.]*')}")
90+
# Directories only - hidden files are dropped by `hidden_match` - and two
91+
# patterns, because the two greps match them against different things.
92+
# GNU grep tests a directory's base name (`.git`), which the first
93+
# matches. BSD grep (macOS) tests the path as it walks it (`./.git`,
94+
# `./src`), which the second matches for a hidden directory only: `.*`
95+
# there excluded the starting directory, and `.[!.]*` excluded every
96+
# subdirectory, since `[!.]` matches the `/` of `./src`. Quoted, or the
97+
# shell expands the patterns against the working directory.
98+
options.append(f"--exclude-dir={shlex.quote('.[!./]*')}")
99+
options.append(f"--exclude-dir={shlex.quote('*/.[!.]*')}")
97100
if glob:
98101
options.append(f"--include={shlex.quote(glob)}")
99102
return f"grep {' '.join(options)} -e {shlex.quote(pattern)} {shlex.quote(path or '.')}"

‎src/pydantic_ai_backends/toolsets/_workspace.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
)
2929
from wcmatch import glob as wcglob
3030

31+
from pydantic_ai_backends._confined import confinement
3132
from pydantic_ai_backends._editing import Replacement, replace_in_content
3233
from pydantic_ai_backends._limits import MAX_EXECUTE_OUTPUT_BYTES
3334
from pydantic_ai_backends._text import bytes_to_text
@@ -229,7 +230,11 @@ async def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
229230
230231
Through `find` where the workspace runs commands, and by walking its
231232
directories otherwise, so a read-only or file-only workspace can search.
233+
234+
Raises:
235+
WorkspacePathError: The workspace is confined and `path` leads out of it.
232236
"""
237+
await self._check_search_root(path)
233238
if self._runs_commands():
234239
return _shell.parse_glob(await self._search(_shell.glob_command(pattern, path)))
235240
root = "." if path.strip() in _shell.ROOT_SPELLINGS else path
@@ -256,6 +261,10 @@ async def grep_raw(
256261
Through `grep` where the workspace runs commands, and by reading its files
257262
otherwise. Either way a binary file is skipped rather than matched.
258263
"""
264+
try:
265+
await self._check_search_root(path)
266+
except WorkspaceError as error:
267+
return f"Error: {error}"
259268
if self._runs_commands():
260269
command = _shell.grep_command(pattern, path, glob, ignore_hidden)
261270
found = _shell.parse_grep(await self._search(command))
@@ -294,6 +303,20 @@ async def grep_raw(
294303
)
295304
return matches
296305

306+
async def _check_search_root(self, path: str | None) -> None:
307+
"""Refuse a search rooted outside a `ConfinedWorkspace`.
308+
309+
`find` and `grep` run as commands, which the confinement leaves alone, so
310+
the root is checked here. Neither follows a symlink below the root - `find`
311+
never does, `grep -r` only for one named on its command line, which is the
312+
root itself - so the root is the one path that needs it.
313+
"""
314+
confined = confinement(self._workspace)
315+
if confined is not None:
316+
await confined.check(
317+
"." if path is None or path.strip() in _shell.ROOT_SPELLINGS else path
318+
)
319+
297320
async def _search(self, command: str) -> ExecuteResponse:
298321
"""Run a `find` or `grep`, letting a workspace failure raise.
299322

‎src/pydantic_ai_backends/workspaces/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@
1313
| `DaytonaWorkspace` | A Daytona sandbox | `daytona` |
1414
| `StateWorkspace` | A JSON document, files only | — |
1515
16+
`ConfinedWorkspace` wraps any of them, or Pydantic AI's local workspace, to keep
17+
file operations inside its working directory.
18+
1619
Compose one with tools that use the workspace: this library's
1720
`ConsoleCapability`, or the harness's `Coder`, `Shell` and `FileSystem`.
1821
"""
1922

23+
from pydantic_ai_backends._confined import ConfinedWorkspace, WorkspacePathError
2024
from pydantic_ai_backends.workspaces._daytona import DaytonaWorkspace, DaytonaWorkspaceBackend
2125
from pydantic_ai_backends.workspaces._docker import DockerWorkspace, DockerWorkspaceBackend
2226
from pydantic_ai_backends.workspaces._kubernetes import (
@@ -27,6 +31,7 @@
2731
from pydantic_ai_backends.workspaces._state import StateWorkspace, StateWorkspaceBackend
2832

2933
__all__ = [
34+
"ConfinedWorkspace",
3035
"DaytonaWorkspace",
3136
"DaytonaWorkspaceBackend",
3237
"DockerWorkspace",
@@ -37,4 +42,5 @@
3742
"SandboxdWorkspaceBackend",
3843
"StateWorkspace",
3944
"StateWorkspaceBackend",
45+
"WorkspacePathError",
4046
]

‎tests/test_shell_commands.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ class TestGrep:
8484
def test_hidden_files_are_excluded_by_default(self):
8585
command = _shell.grep_command("todo")
8686

87-
assert "--exclude-dir='.[!.]*'" in command
87+
assert "--exclude-dir='.[!./]*'" in command
88+
assert "--exclude-dir='*/.[!.]*'" in command
8889
assert "--exclude=" not in command
8990

9091
def test_hidden_files_can_be_included(self):
@@ -163,9 +164,10 @@ def test_a_pattern_starting_with_a_dash_is_a_pattern_not_an_option(self):
163164
assert shlex.split(command)[-3:] == ["-e", "-v", "/w"]
164165

165166
def test_the_hidden_excludes_stay_quoted(self):
166-
"""Unquoted, the shell expands `.*` against the working directory."""
167-
assert "--exclude-dir=.[!.]*" in shlex.split(_shell.grep_command("x", "/w"))
168-
assert "'.[!.]*'" in _shell.grep_command("x", "/w")
167+
"""Unquoted, the shell expands them against the working directory."""
168+
argv = shlex.split(_shell.grep_command("x", "/w"))
169+
assert {"--exclude-dir=.[!./]*", "--exclude-dir=*/.[!.]*"} <= set(argv)
170+
assert "'.[!./]*'" in _shell.grep_command("x", "/w")
169171

170172

171173
class TestHiddenExclusionKeepsTheStartingDirectory:
@@ -181,6 +183,18 @@ async def test_a_search_of_the_working_directory_finds_files(self, tmp_path) ->
181183
assert isinstance(matches, list)
182184
assert [m["path"].removeprefix("./") for m in matches] == ["visible.txt"]
183185

186+
async def test_subdirectories_are_searched_and_hidden_ones_skipped(self, tmp_path) -> None:
187+
"""BSD grep matched `.[!.]*` against `./src` and skipped every subdirectory."""
188+
from pydantic_ai_backends.toolsets._workspace import WorkspaceOps
189+
from tests.support import local
190+
191+
for path in ("src/app.py", "a.b/c.txt", ".git/HEAD", "src/.cache/x"):
192+
(tmp_path / path).parent.mkdir(parents=True, exist_ok=True)
193+
(tmp_path / path).write_text("needle")
194+
matches = await WorkspaceOps(local(tmp_path)).grep_raw("needle")
195+
assert isinstance(matches, list)
196+
assert sorted(m["path"].removeprefix("./") for m in matches) == ["a.b/c.txt", "src/app.py"]
197+
184198

185199
class TestHiddenMatch:
186200
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)