Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
[0.2.29] - 2026-08-22¶
Fixed¶
- A tool's return description now reaches the model the way pydantic-ai sends
every other one.
renderappended a proseReturns: …paragraph, where the framework wraps a docstring'sReturns:section in<summary>and<returns>tags — so a host registering these beside tools of its own put two conventions in one tool list,create_chartarriving as XML andlsas prose describing the same kind of thing.renderemits the framework's shape now, andtests/test_tool_text.pypins it against a tool pydantic-ai renders itself, so a change on that side fails here rather than leaving these tools speaking the old dialect. The*_DESCRIPTIONconstants carry the new shape with them; a catalogue wanting prose readsToolText.summary, which is what it wanted.
[0.2.28] - 2026-08-22¶
Changed¶
- Every console tool now says what it returns, and its text is one object
rather than two. A tool definition is a prompt, and these were written as
Claude Code's are: shouty (
NEVER,ALWAYS,MUSTandIMPORTANTbetween them 16 times), thin on arguments, and silent on the one thing a model cannot infer — the shape of the answer. Nothing said thatgrepreplies in three different shapes depending onoutput_mode, thatgloblists 100 paths andgrep50 before summarising the rest as... and N more, thatread_file'soffsetcounts from 0 while the line numbers it prints count from 1, that a failedexecutestill returns its output underCommand failed (exit code N), or that a timeout answersError: Command timed outwith code 124. All of it is in the descriptions now, under aReturns:paragraph.
The mechanism behind that: each tool's text is a ToolText — summary,
usage, coding, args, returns — and the description handed to the model
is rendered from it, while args becomes the per-argument text in the JSON
schema. Previously the description was a constant and the argument text was a
docstring beside the function, so a host could override one and not the other,
and docs/api/toolsets.md held a third copy that had gone stale. The
*_DESCRIPTION constants are still exported and are rendered from the same
objects.
tests/test_tool_text.py holds the drift shut: every argument of every tool in
both edit formats carries a description, and TOOL_TEXT names exactly the
arguments the function takes — an argument the registry forgets reaches the
model undescribed, and one it names that no longer exists is stale text nobody
would notice.
Added¶
- A mistake the model can fix now raises
ModelRetry, and a refusal still does not. Every failure used to be a returned string, including the ones the model had plainly got wrong: anold_stringmatching three places, a file edited between the read and the edit, a path that does not exist. The model was told in a sentence indistinguishable from a real answer and left to notice. Those cases — a missing file, an offset past the end, an absent or ambiguousold_string, a stale read, a hashline hash that no longer matches — come back as a retry prompt now.
What stays a returned string is as deliberate: a non-zero exit from execute is
a result to reason about rather than a malformed call, grep finding nothing
is an answer, a dropped connection is not something different arguments would
fix, and a permission refusal must never be a retry, because a retry prompt
invites the model to look for a way around the rule. PERMISSION_DENIED_PREFIX
is now one constant that PermissionGuard writes and toolsets/_failures.py
reads, with a test holding the two ends together.
max_retries becomes a floor as well as a ceiling: on a tool's last attempt the
message is returned rather than raised. ModelRetry past the budget ends the
whole run with UnexpectedModelBehavior, so without that floor a model that
mistyped an old_string twice would kill a run that used to carry on. The worst
case is now exactly the old behaviour, never a dead run — and max_retries=0
reproduces it entirely.
-
profile="agent", for a host whose agent is not working in a repository.executecarried 2501 characters of coding-agent guidance — git safety, package managers, what to do after three failed attempts — on every request, including for an agent whose workspace is scratch space for one conversation. That guidance is now thecodinghalf of the text, kept under the defaultprofile="coding"and dropped under"agent": about 240 tokens a request across the seven tools a typical host registers, and the return shapes are kept either way. -
descriptionsaccepts aToolText, and refuses an unknown key. A string still replaces the description alone; aToolTextreplaces the argument text with it, which was previously unreachable from outside the library. A key that is not a tool name now raisesUserError— it used to be ignored, so a misspelled or renamed key meant an override that silently reached nothing, including for a host whose own catalogue then showed one text while the model read another.
[0.2.27] - 2026-08-20¶
Fixed¶
- A glob was rooted in the machine rather than in the workspace.
glob_commandpassed its root through tofind, and a caller naming the backend's root spells it/,""or.— which is the top of the namespace for a backend addressing files by virtual path and the filesystem root for a shell. Measured against a runningsandboxdin a session holding three files,glob("*")answered 2540 paths, all of them outside the workspace (/proc,/usr, the base image),glob("*.txt")answered 25 of which 22 were, andglob("./**/*")answered none at all. So an agent's own search tool read the image it runs on into its context, and any caller diffing two globs to learn what changed during a turn was comparing two photographs of/proc. The root resolves to.now — the session's working directory — and an absolute path is still passed through, because/etcis a root a caller may mean. (#106) **/*missed every file at the top level, and it is the pattern anything walking a tree reaches for.find -pathmatches with fnmatch, where**is no different from*and every/in the pattern must be present in the path, so the prefix required two slashes. A leading**/means "at any depth", which is exactly what the*/prefix already provides, so it is dropped rather than stacked. (#106)
[0.2.26] - 2026-08-16¶
Added¶
FileInfo.modified_at— an optional ISO 8601 timestamp, filled by every listing that has a real one to give.StateBackendsurfaces the timeFileDataalready records on each write (a document persisted before the key existed listsNone);LocalBackendreportsst_mtimeonlsandglob; a stored workspace archive reports it through the wire, wherewire.FileEntry.modified_atdefaults toNoneso a client and a service on either side of this release keep understanding each other; a Kubernetes pod's in-pod server may send one and an older image simply does not.
Shell-derived listings (docker/daytona exec) leave the key absent: ls -la
output has no timestamp that survives locale, busybox and timezone, and a
guessed time is worse than none. Read it with .get("modified_at") and treat
a missing key as unknown, never as "just now". (#104)
[0.2.25] - 2026-08-04¶
Four open issues, and the first is the one to read.
Fixed¶
- A
PermissionRuleset's per-path rules are enforced. Handing one toConsoleCapabilityreached two things —requires_approval, for the write and execute approval flags, and_denied_tools, which drops a tool whose operation defaults to"deny". Nothing readOperationPermissions.rules. So the shape a caller writes when they want "allow the workspace, deny credentials and the system tree" — every operationdefault="allow", the patterns inrules— was no enforcement at all:/etc/passwdand**/.envread and wrote freely, andgrepreturned their contents line by line. Worse than rejecting the ruleset, because it looked like a working boundary.
PermissionGuard had been here since LocalBackend needed it and only
LocalBackend ever used it. GuardedBackend puts it on any backend, applied
where the toolset resolves its backend — the one place every tool passes
through. A caller with no ruleset, or a backend enforcing its own, is untouched,
so nothing changes for anyone who was not already expecting this to work.
grep is filtered rather than refused, because a match carries the file's line;
ls and glob filter on their own rules and not on read, matching
LocalBackend. Also fixed a symlink hole in the command check: paths were
resolved before matching, so on macOS /etc/passwd became /private/etc/passwd
and a rule reading /etc/** matched nothing. (#97)
- One signature for
stopacross every backend.RemoteSandbox.stop(purge),DockerSandbox.stop(remove)andDaytonaSandbox.stop()meant a caller holding "a sandbox" could not call it — and theTypeErrorlanded inside teardown, which is wrapped in a broadexcept, so the call that should have released the resource was the one that raised. A Daytona sandbox was never deleted on any path, once per run, on the account paying for it.
purge everywhere, False by default everywhere. DockerSandbox.stop(remove=)
still works and warns; nothing that called stop() needs to change. (#98)
Added¶
-
WorkspaceArchive.read_bytes, andPOST /workspaces/{id}/read_bytesbehind it.readreturnsstr, so a chart, a rendered PDF or an image came back decoded and re-encoded — a corrupt file that downloads successfully, which is worse than an error. Consumers had to allowlist text suffixes, which left the container backend as the one whose outputs could not be fetched. The service'smax_read_bytesstill applies: this returns a whole file. (#96) -
PUT /policyandSANDBOXD_POLICY_OVERRIDES— change the ceilings and lifetimes without a restart, which used to drop every resident sandbox on the host. Both go through one function, so the endpoint and the file cannot drift into disagreeing about what is in force.
Ceilings and lifetimes only. runtimes membership, network_mode,
oci_runtime, sandbox_uid, work_dir, persist_containers and prewarm are
refused by name with a 422 — adding an alias means naming an image, and
network_mode: host is not a ceiling but an escape. A change applies to the
next sandbox; Docker sets the limit on the container, so a resident one keeps
what it was created with. (#95)
[0.2.24] - 2026-08-03¶
Added¶
ConsoleCapabilityforwards the toolset options it was hiding:image_support,max_image_bytes,document_support,max_document_bytesanddescriptions. The capability is the recommended entry point and builds the toolset itself, so an option it did not forward was an option nobody using it could reach —edit_formatwas the same bug one step later, reaching the instructions while the toolset kept registeringedit_file.
image_support is the one that changes what an agent can do: without it a
multimodal model reading a .png gets the bytes as garbled text, so an agent
cannot look at a chart it rendered a moment ago. descriptions matters to a
host that lists these tools in its own catalogue — without it the text shown
to whoever decides what to allow and the text read by the model deciding when
to act are written in different repositories, and drift silently.
[0.2.23] - 2026-08-03¶
Dependency updates to the pieces 0.2.22 introduced. No library code changed.
Changed¶
- The
sandboxdimage is built onpython:3.14-slim(#87). It is the runtime for the service process only and has nothing to do with the sandboxes it starts, or with the Python versions the library supports. CI builds the image and starts it on every pull request, so the[server]extra installing and the service serving/healthzon 3.14 is checked rather than assumed. docker/build-push-actionv6 → v7 (#88). The major is Node 24, an ESM switch and the removal of two deprecated environment variables and the legacy build summary tool — none of which touch the inputs used here.
[0.2.22] - 2026-08-02¶
Everything a deployment needs to run sandboxd without writing Python, and the
fix for a StateBackend that could not actually be persisted.
Added¶
- A published image:
ghcr.io/vstorm-co/sandboxd. The service exists so an application never needs the Docker socket, and the answer to "how do I run it" was "write a Dockerfile" — friction at exactly the point where somebody decides whether the safe path is worth taking. Built from the tag's source, taggedX.Y.Z,X.Yandlatest,linux/amd64andlinux/arm64.
It runs as uid 10001, so reaching the socket needs group_add: ["${DOCKER_GID}"]
— that is the one failure to expect. Running unprivileged does not stop the
process being host-root-equivalent (anything that can reach the daemon can
start a privileged container), but it does bound a bug in the service itself to
files it owns. CI builds the image and starts it on every pull request, because
a Dockerfile only built at release time is one that breaks at release time.
- Every
SandboxdConfigfield is configurable from the environment. The entrypoint read four variables while the dataclass models thirty, so any deployment needing the other twenty-six wrote its own launcher — and each got a different subset right. Each field is nowSANDBOXD_plus its name in upper case, with one vocabulary across the dataclass, the environment and the docs.
Absent takes the shipped default; empty means None on a field that has
one, so SANDBOXD_CPUS= says "no hard ceiling" — something absent cannot say.
Booleans take 1/0, true/false, yes/no, on/off. SANDBOXD_RUNTIMES
grew past alias=image: @name builds one of the shipped catalogues,
;field=value sets that runtime's own ceilings, and a JSON object expresses a
runtime whose package list is written out rather than named. A bad value, or a
combination the service refuses, fails at startup naming the variable instead
of at the first request.
The parsing is config_from_env — a pure function of a mapping, so a
deployment's configuration can be asserted in a test rather than discovered by
starting a container.
- CI now tests both ends of the declared
pydantic-airange. Theconsoleextra says>=1.74.0and every job installed whateveruv.lockpinned, so the range was never exercised at either end. An application on 2.x installed this library cleanly and found out at runtime whether the capability hooks still behaved — quietly, in the direction that matters: aprepare_toolssignature that no longer matches stops hiding the tools a ruleset denied. Both 1.74.0 and the newest release are now tested on every pull request. Both pass today; no code change was needed, only the job that proves it.
Fixed¶
- A
StateBackendholding a binary file could not be serialised.FileData.contentis lines of text andwritedecoded bytes witherrors="surrogateescape", which round-trips exactly in Python — which is why it survived.json.dumpsemits the lone surrogates without complaint andjson.loadsreads them back, so a Python-only test sees nothing wrong. Nothing stricter accepts them: PostgreSQLjsonbrejects an unpaired escape outright, atextcolumn cannot hold one, and encoding the document as UTF-8 — what any driver does — raises.
So the one thing this backend is otherwise ideal for, a workspace a host persists between turns, broke the moment an agent wrote a PNG into it, and it broke at the storage layer rather than at the write that caused it.
Content that is not valid UTF-8 is now stored base64 with encoding: "base64"
on the entry, and backend.files is a JSON document whatever is in it. Text is
unchanged, including bytes that decode as UTF-8 — a script written as bytes
stays readable, greppable and editable. read, edit and grep decline to
treat a binary file as text rather than showing its encoded form; read_bytes
returns exactly what was written. A document written before encoding existed
still loads, and its surrogates still encode back to the bytes they stand for.
Changed¶
readon a binary file inStateBackendreturnsError: ... is binaryrather than mojibake,editrefuses it, andgrepskips it. Previously each operated on the surrogate-escaped text, which produced matches at line numbers the file does not have.ls_infoandglob_inforeport a binary file's decoded size, not the length of its base64.SANDBOXD_RUNTIMESunset now takes the shippedDEFAULT_RUNTIMESallowlist rather than a single hardcodedpython=python:3.12-slim.
[0.2.21] - 2026-08-01¶
A full audit of the codebase, and the ten findings it produced. Nothing here is a new feature; several are behaviour changes, and the security one is worth reading before upgrading is deferred.
Security¶
-
A denied
executenow removes every shell tool, not justexecute.create_console_toolset(permissions=...)promises that an operation defaulting to"deny"drops its tools entirely, and_denied_toolslisted onlyexecute— sorun_in_background, which runs an arbitrary command, stayed registered. With the shippedREADONLY_RULESET, whose docstring reads "nothing may change or run", the model was still handed a working shell.ConsoleCapabilitywas worse: the background tools were absent fromTOOL_OPERATIONS, and an unmapped name is both kept byprepare_toolsand waved throughbefore_tool_executeunchecked, so no permission check ran on them at all — via the example in the capability's own module docstring. All five tools now live and die with theexecuteoperation, the capability passes its ruleset to the toolset as well as filtering per request, and a test asserts the tool/operation map covers every registered tool so the next tool cannot repeat this. -
grep_rawno longer interpolates the pattern into a shell command unquoted. Every other value in_shellgoes throughshlex.quote; the grep pattern and glob were wrapped in literal single quotes, which one of their own closed. A search fordon'tproduced an unterminated command and a crafted pattern ran whatever followed it, onDockerSandbox,DaytonaSandbox,KubernetesPodSandbox(mode="api")and any third-partyBaseSandboxsubclass. Both are quoted now, and the pattern is passed with-eso one starting with-is a pattern rather than an option.
Fixed¶
-
path="."no longer returns nothing on the virtual-path backends.normalize_pathprefixed a slash without resolving the segment, so"."— the console toolset's default forlsandglob— became"/.", a directory no file is ever stored under.StateBackendandCompositeBackendtherefore answered every default listing, glob and grep with nothing at all, and an agent was told its workspace was empty with no error anywhere."/","","."and"./"now all mean the root, and the composite's fan-out recognises them. -
SessionManager.releasetakes the session's lock. It mutated_sessionsand_lockswith no lock at all, so a release landing inside a concurrentget_or_createdeleted the entry that call was about to delete — aKeyErrorout of a public coroutine — and deleted the lock it was holding, after which the next caller interned a fresh one and two tasks created a sandbox for one session. Locks are now reference-counted while in use rather than pruned onLock.locked(), which reads False between a holder releasing and the woken waiter resuming. -
The service's command ceiling applies to every operation.
SandboxdConfig.execute_timeoutis documented as "a hard ceiling applied to every command, so one client cannot occupy a worker indefinitely" and was applied to/execalone.ls,glob,grep,readandwritereach the sandbox's shell too, andBaseSandboxpassed a timeout forexistsand nothing else — so one slow search pinned a worker thread that nothing could reclaim, andmax_workersof them wedged the service. The derived operations now carryFILE_OP_TIMEOUT(30s) orSEARCH_TIMEOUT(120s), and every sandboxd operation route is bounded by the ceiling, answering504past it. -
RemoteSandboxwaits as long as the service will run a command.TRANSPORT_SLACK_SECONDSexists so the transport never gives up first, but the client's default (60s) and the service's ceiling (300s) were set independently. Anything in between was reported to the agent assandbox service unavailablewhile the command was in fact still running — and typically retried, starting a second one. The ceiling is now read fromGET /policywhen the session opens and exposed asRemoteSandbox.server_timeout, falling back to the local timeout when the service will not say. -
Shell-derived
writeandread_bytescarry binary intact. The protocol typescontentasstr | bytes; both base classes narrowed it tostr, and the heredoc interpolated bytes as their Python repr — writing the 17 charactersb'\x89PNG\r\n'into the file with no error.read_byteswas lossy in the other direction:catreturns its output through an exec stream a sandbox decodes witherrors="replace", so every byte that is not UTF-8 came back as U+FFFD. Both now travel base64-encoded, so a file written through one and read through the other is byte-identical — which is what the console toolset'simage_supportneeds on a shell-derived sandbox. The write side also stops appending the trailing newline a heredoc could not avoid:write(path, "x\n")produced"x\n\n".
Two consequences. The sandbox image needs base64 (GNU coreutils and
BusyBox both provide it, alongside the cat this replaces). And a read_bytes
whose output is truncated by the execute ceiling now returns b"" rather than
a prefix: base64 cut short decodes to bytes that are not the file's, and the
caller cannot tell a wrong answer from a right one.
Verified byte-for-byte over all 256 byte values against real python:3.12-slim,
node:20-slim and alpine:3.20 containers, which CI does not cover.
-
LocalBackend.editreturns its failure instead of raising. It read the file as strict UTF-8 and caught onlyPermissionErrorandOSError;UnicodeDecodeErrorsubclassesValueError, so a file that is not text raised straight out of a backend the protocol promises never raises. It is now refused with an error, and the file is left untouched — decoding with replacement would substitute U+FFFD and write it back. -
edit_fileserializes on the same lockhashline_edituses. Both are a read, a replace and a write; only one was locked, so two concurrent edits to a path lost one of them. The staleness check moved inside the lock too — checked outside, it was answered before the other edit's write. -
LocalBackend.ls_infosurvives an entry that vanishes mid-listing.glob_infowas hardened against exactly this and its sibling was not, so a file removed betweeniterdirandstatraised out of the whole listing and took the directory's other rows with it. -
ConsoleCapabilityacceptsask_callbackandask_fallback. It exposedpermissionsbut built its checker with the defaults, so every operation resolving to"ask"raisedPermissionAskErrorwith no way to answer it — which madeDEFAULT_RULESETandSTRICT_RULESETunusable through the capability. It also gainedinclude_background. -
Smaller ones.
StateBackendreported a size one byte short per line and silently corrupted non-UTF-8 writes (write/read_bytesnow round-trip byte for byte viasurrogateescape);sandboxd's/eventsraisedKeyError— a 500 — where its siblingdescribecorrectly 404s on the same race; a malformed glob in a permission rule raisedre.errorout of the permission check and is now inert; and a docstring inServicePolicysat under the wrong field.
[0.2.20] - 2026-08-01¶
Added¶
SandboxdConfig.sandbox_uid, which runs sandboxes as an unprivileged user. A container runs as root unless told otherwise, and that is two problems: an escape starts from uid 0, and every file an agent writes into its bind-mounted workspace is owned by root on the host, so asandboxdrunning unprivileged cannot clean up after its own sessions. Set the uid and built runtimes are built around it — a real account (a bare numeric id breaks everything callinggetpwuid,whoamifirst), a home directory it owns (withHOMEleft at/, the firstpip installfails onPermission denied: '/.local'), and a virtualenv it owns first onPATH. That last one is what makes it workable rather than merely safer: a non-root user cannot write to the interpreter's ownsite-packages, anduv— unlike pip — has no--usermode to fall back on, so without a virtualenv it fails with no way forward. Measured in that shape:whoami,git commit,pip install,uv pip installand running the installed tool all succeed, while/etcand the systemsite-packagesare refused.
Off by default, because it changes filesystem ownership. It asks two things of the deployment: the service must be able to give each workspace to that uid — with the privilege to chown, or by running as it — and a service that can do neither is told so when the session opens rather than starting a sandbox whose first file write would fail. Ready-made runtimes stay as root, since an image nobody built for this has no such user and no virtualenv, so an agent inside one could install nothing.
- RuntimeConfig.run_as_uid, the library-level form of the same thing for anyone building a DockerSandbox directly.
[0.2.19] - 2026-08-01¶
Changed¶
SandboxdConfig.default_runtimedefaults to the first entry inruntimesrather than to the literal alias"python". The old default meant every custom allowlist had to contain a key namedpythonor the config refused to construct, which is a coupling nothing asked for. The shipped allowlist listscodingfirst, so that is what a default service now hands out; naming an alias explicitly still wins, and naming one that is not allowed is still refused.
Fixed¶
- An agent can use git in its sandbox. Every git command in a bind-mounted workspace failed with
detected dubious ownership, because the directory belongs to whoever the service runs as and the container does not — measured onstatus,diff,logandcommitalike. Past that, a commit failed again withAuthor identity unknown. Both are now configured throughGIT_CONFIG_*on the container, which is what makes it reach the ready-made runtimes (bun,deno,go,rust) that build no image of their own. - A long-lived session no longer runs out of processes. Containers run
sleep infinityas PID 1, andsleepnever callswait()— so every process an agent orphans, a backgrounded server or anything the command timeout kills, was reparented to it and stayed a zombie for the life of the container. Measured: ten orphans, ten permanent zombies, accumulating againstpids_limit(512) until every command in the session failed to fork. Containers now start with Docker'sinit, which costs 488 kB and reaps them.
Added¶
- An evicted session is hibernated rather than closed. At the ceiling,
sandboxdused to close the least recently used idle session: its container went, and so did its token, its event log and the caller's ability to come back to it. It now gives up only the sandbox. The record stays,GET /sessionsreports it asstate: "hibernated", and the next request wakes it where it left off — measured at 0.09 s for a persisted container. Nothing a client holds stops being valid. SandboxdConfig.max_open_sessions, which is the point of the above.max_sessionsnow means resident sandboxes — the number the host's RAM has to hold — whilemax_open_sessionsbounds the sessions that exist at all, resident and hibernated together, which is a disk number and properly much larger. On a 4 GB host that is ten resident against a couple of hundred open. At the open ceiling the longest-asleep session is closed for good; with every open session in use, the caller gets429. A hibernated session is ended byidle_timeoutlike any other.memswap_limitonDockerSandbox,SandboxRuntimeandSandboxdConfig. Swap is still pinned tomem_limitby default, because a container swapping to a disk starves every other one on the host. That is the wrong trade where swap iszram: the pages stay in RAM compressed at roughly 3:1, and the alternative to a little swapping is an OOM kill mid-command. Set it abovemem_limitthere and nowhere else.- A
codingruntime, and it is the shipped default. Python with git, ripgrep, fd, jq, less, procps anduv— 99.7 MB, eleven seconds to build, measured.gitis 33.1 MB of that and unavoidable; the five tools an agent looks at a codebase with come to 4.3 MB between them.build-essentialis deliberately absent at 94 MB to compile wheels manylinux already ships built. It is the first entry inDEFAULT_RUNTIMES, withpythonandnodestaying beside it as ready-made fallbacks for a host that cannot reach a Debian mirror. - Every sandbox starts with a working environment, applied at the container so it reaches images we did not build:
PYTHONUNBUFFEREDso a command killed by the timeout still returns what it printed,NO_COLORandPAGER=catso escape sequences do not fill the model's context,LANG=C.UTF-8becausenode:20-slimships no locale, andUV_CONCURRENT_DOWNLOADS=2— uv's parallelism is memory, and uncapped it is OOM-killed by a 128 MB ceiling that pip survives, where capped it fits and stays 6.6× faster than pip. A runtime overrides any of it through its ownenv_vars. polyglotnow carries npm 10. Debian 13 ships a current Node (20.19.2 againstnode:20-slim's 20.20.2) but an npm a major version behind, so the generalist runtime quietly behaved differently from the dedicated Node ones.scripts/bench_density.py, which measures on the host being sized what no blog post can: marginalMemAvailableper session, per-container management overhead, time to first command, and wake latency from hibernation. Reasoning behind it indocs/plans/sandbox-density-on-small-hosts.md.
[0.2.18] - 2026-08-01¶
Added¶
KubernetesPodSandboxandDaytonaSandboxare covered. The last two class-level# pragma: no cover; no blanket one remains in the package. 30 tests cover the pod-exec path, both readiness outcomes and the polling in between, liveness, config loading, themode="http"listing failures and themode="api"fallbacks to the shell — plus Daytona's readiness probe and failure handlers. The two exec bugs above are what the pass found.- The console tools' own rendering is covered. Every tool body sat behind a blanket
# pragma: no cover, so how a listing, a glob, a grep or a background shell is actually rendered for the model was unmeasured — and bothhashlinevariants, which register only underedit_format="hashline", had no coverage at all. 29 tests now cover them: the empty and truncated forms ofls,globandgrep,grep's count mode and its error passthrough, a hashline read and a full hashline edit round trip including a stale hash and a failing write-back, and the background tools with and without a sandbox that has a shell. LocalBackend's failure and denial paths are covered. 30 of its methods' error handlers sat behind a blanket# pragma: no cover, so in the backend most users touch, the code that turns a denied path or a filesystem error into a reportable result was never run by a test — including everyPermissionErrorhandler on its permission boundary. The pragmas are gone and 45 tests cover it: a path outside the allowed directories for every operation, an unreadable file and directory, an offset past the end, a read of a directory,OSErroron read, write, edit and glob, and both grep implementations forced explicitly rather than left to whetherrghappens to be installed — which is what made this file's coverage depend on the machine. The glob truncation above is what the pass found.
Fixed¶
- A Kubernetes exec no longer reports an unknown status as success.
_execute_apidefaulted a missing return code to0, and the read loop exits as soon as the output cap is hit — with the command still running and no code yet. So every truncated command was reported as having passed. It now defaults to1, matchingLocalBackend, which had the convention right. - A Kubernetes exec no longer leaks a websocket on failure.
resp.close()sat after the read loop inside the sametry, so a connection dropping mid-command skipped it entirely — one leaked socket per failed command, for the life of the process. Moved to afinally. -
A glob no longer silently returns a short answer.
LocalBackend.glob_infowrapped its whole walk in onetry, so a single entry that could not be stat'd — deleted between the glob and the stat, or in a directory the process cannot read — aborted the loop and returned whatever had been collected. Measured on four matching files with one bad entry: one came back. The model gets an incomplete answer with nothing to indicate it, which is worse than a missing row and worse than an error. Now skipped per entry, matchingls_infoandgrep_raw, which both already carried on. -
A listing no longer reports paths the sandbox cannot read back.
ls_infobuilt each row'spathfrom the shell-quoted directory, so listing/my workreturned'/my work'/notes.md— a path that does not exist. A model handed that row and asking to read it got a failure it could not recover from, and the directory was effectively unreachable. Plain paths quote to themselves, which is why it went unnoticed. Affects every shell-derived sandbox: Docker, Daytona, Kubernetes and any third-party one. -
is_async_backendis importable from the package root, alongsideensure_async. 0.2.17 announced it as public API and documented it, but only added it toadapter.py— sofrom pydantic_ai_backends import is_async_backendraisedImportErrorand the only way in was the submodule path.
[0.2.17] - 2026-08-01¶
Added¶
AsyncBaseSandbox— the shell-derived file operations, for a sandbox that is natively asynchronous.BaseSandboxgives a subclass every operation for the price of implementingexecuteandedit, but only synchronously, so an author reaching a sandbox over asyncssh or an async HTTP SDK got nothing from it and hand-wrote all eight:ls -laand its parsing,awkfor a numbered read,find -pathfor a glob,grep -rn, a heredoc write. That is a reimplementation of a class we already ship, and one that drifts from ours the moment either changes. Both bases now derive their operations from one internal module, so a subclass implementsexecuteandedit— as coroutines — and nothing else.
Subclassing it rather than wrapping async code in a synchronous facade is not a style question. ensure_async cannot see through a facade: it wraps it in a thread adapter, so each call occupies a worker thread that then has to hop back onto the event loop to reach the real async code. A sandbox whose own recovery path also needs a thread — reprovisioning a dead container — waits for a thread that is waiting for the loop, and starves the pool for every other agent sharing it. Reported from a deployment where exactly that froze a whole single-loop runtime rather than one tool call.
is_async_backend, andensure_asyncrecognisingAsyncBaseSandboxby its class. Whether a backend counted as already-async was decided by a single undocumented method-shape check —read_bytesbeing a coroutine function — which third-party authors were reading out of our source and depending on. It also had a hole: the adapter accepts the legacy_read_bytesname but the check did not, so a backend async everywhere while spelling it the old way was classified as sync, thread-wrapped, and handed its caller a coroutine object where bytes were expected — no exception, just nonsense. Both names are now checked, the base class is recognised outright, and the contract is documented rather than inferred. It has to stay a shape check rather thanisinstanceagainstAsyncBackendProtocol, because a runtime-checkableProtocolcompares method names and a sync backend has exactly the same ones.
Fixed¶
AsyncBaseSandboxnow works withSessionManager, and therefore withsandboxd. It did not, silently, in three separate ways:startwas handed to a worker thread, which called the coroutine function and dropped the coroutine, so the sandbox never started;is_alive()returned a coroutine object, which is truthy, so a dead sandbox was reused for the life of the process; andstopwent the same way asstart, so nothing was ever stopped. None of it raised — the only symptom was an "unawaited coroutine" warning nobody reads. Lifecycle calls are now awaited when the sandbox is async and threaded only when they block, andalive_ofresolves liveness either way. The service had the same three problems indescribe,resource_usageand?purge=true, so an async sandbox reported as permanently alive with no usage and survived its own purge.- A sandbox found dead is stopped before being dropped.
get_or_createdeleted it from the registry and left it to garbage collection, which for a sandbox holding an SSH connection or anhttpx.Clientmeans an unclosed socket and a warning at interpreter exit.DockerSandboxhad a__del__as a safety net;RemoteSandboxand any third-party backend did not. - A backend's exception no longer ends the agent's run. The console toolset's
executetool caught onlyRuntimeError, andexecute_backgroundonlyRuntimeErrorandPermissionError. Every bundled backend returns its failures rather than raising, so nothing here exercised it — the cost fell entirely on third-party backends, which arrive with whatever their transport raises:OSErrorfrom a dropped socket, an SSH or HTTP client's own error class,TimeoutError. Any of those escaped the tool and ended the run instead of failing one call. Every tool now guards, not just the two that were reported: the file operations reach the same backend over the same transport, so there was no reason one would raise and another would not.
The guard deliberately lets pydantic-ai's control-flow exceptions past — ModelRetry, ApprovalRequired, CallDeferred and the Skip* family. Those are not failures, they steer the run, and catching ModelRetry in particular would turn a retry into a dead end the model cannot recover from. UserError passes through too, because reporting a misuse of the library to the model as a failed file operation hides the bug; it subclasses RuntimeError, so the narrower handler this replaced was already swallowing it.
- The shell derivation every sandbox depends on is measured. BaseSandbox carried a blanket # pragma: no cover, so the command construction and output parsing behind Docker, Daytona, Kubernetes and any third-party sandbox contributed nothing to the 100% gate. Moving it into one module made it directly testable, and it is now covered — including the quoting of a hostile path, ls rows with spaces in the name, a grep line containing colons, and the failure branch of every operation.
- The failure contract is written down. What a backend must return when an operation fails was real, load-bearing and documented nowhere, so implementors were deducing it from our source. protocol.py now states it per method, including the asymmetry that matters most: read may return an Error: string but read_bytes must return b"", because its caller cannot tell an error message from real file content — a probe staging a screenshot would treat b"Error: not found" as the image.
SandboxdConfig(container_ttl=...)removes a persisted sandbox container that has been stopped for that long, leaving its workspace untouched. It separates the two things a session accumulates: what it installed is rebuildable, what it wrote is not — so a deployment can reclaim the first on a schedule while keeping an agent's files for ever, which is what its user expects.workspace_ttlremains the opposite knob and staysNoneby default. The sweep finds containers by their name prefix rather than from a record of its own, because after a restart Docker is the only source of what is still lying around.SandboxdConfig(evict_idle_after=...)turns the session ceiling from a hard cap on how many sessions may exist into a working-set size. At the ceiling the least recently used session idle for at least that long is closed to make room, instead of the incoming request being refused — which was the wrong answer when the pool was full of sandboxes nobody was using. Withworkspace_rootset the evicted session loses nothing but its container: its next request re-attaches and finds its files, for the price of a container start. A session idle for less than the threshold is never a candidate, because killing an agent's work to serve somebody else's first request is worse than making them wait, and a pool of genuinely busy sessions still answers429. Requiresworkspace_root, and the config refuses without it rather than silently discarding an evicted session's files.SandboxdConfig(max_sessions=None)removes the session ceiling, for hosts where something else does the bounding. It was typedintand so could not be uncapped at all.- A
polyglotruntime: Python 3.12 and Node 20 in one sandbox withcurl,git,npmandpipfor installing more, plus numpy, DuckDB, Polars and httpx. For the common agent that writes a script, a page and a stylesheet and fetches something — previously every runtime was single-language. Deliberately without pandas: at 97 MB of import it would take a third of a 256 MB sandbox before doing any work. Measured at 1.2 GB built, shared across every session that uses it, and its imports peak at 87 MB so it fits a 256 MB ceiling. SandboxdConfig(prewarm=True)pulls and builds the whole runtime allowlist in the background as the service starts, so the first session on a built runtime no longer pays for the image build — measured at roughly eleven seconds for a pandas runtime — in the middle of a request. Sequential on purpose: several builds at once would fight over the CPU and disk of exactly the small host this is most worth doing on. A runtime that will not build is logged and skipped rather than stopping the rest, and the service answers requests throughout. Only applies to the default Docker builder, since nothing else knows how to warm an injected one.- Image builds use BuildKit when the
dockerCLI is available, which makes package caches survive between builds: editing one package in a runtime re-downloads nothing. The Python SDK has no BuildKit support and its builder rejectsRUN --mountoutright — verified, not assumed — so the generated Dockerfile only carries cache mounts on the BuildKit path and keeps discarding the cache on the classic one.pip,npm,aptandcargoeach cache the right directory, withsharing=lockedso two concurrent builds do not corrupt one cache; the apt path also removes Debian'sdocker-cleanhook, which would otherwise empty the mount immediately after the install.GET /policyreports which builder is in force. DockerSandbox(tmpfs=...)andSandboxdConfig(tmpfs_size="64m")give each sandbox an in-memory/tmp. Scratch writes previously landed in the container's write layer, which is both slower and the difference between a busy sandbox growing on disk and not. The mount is givenexecexplicitly because Docker mounts a tmpfsnoexecby default — verified — and that breaks installing any package that builds from source.-
DockerSandbox(cpu_shares=...), andcpu_sharesper runtime and service-wide. A hardcpusceiling means a sandbox cannot use cores that are sitting idle, which on a small host is usually the wrong trade: one agent waits at one core while three are unused. A weight applies only under contention, so a single active sandbox may take the whole machine and several are still divided fairly. The two compose. -
DockerSandbox(oci_runtime=...), andoci_runtimeper runtime and service-wide. Docker hands each container to a low-level OCI runtime and takes one per container, but nothing passed it, so an operator could not choose anything but the daemon's default. It is the only setting that changes how strong a sandbox's isolation is rather than what resources it gets:"runsc"(gVisor) moves syscall handling into userspace,"kata"gives the container its own kernel in a microVM, and plainruncshares the host's — which is what every sandbox has been doing while running untrusted model-written code. Per runtime for the same reason the ceilings are: the runtime allowed to install packages off the network is the one worth paying gVisor's I/O overhead for, and a plain shell is not.Noneby default, because naming a runtime the daemon has not registered makes it refuse the container and turns every session into a502.GET /policyand the dashboard report the runtime actually in force.crun— a drop-inruncin C, no different isolation but less overhead per operation — belongs in the daemon's own config and now has a section in the installation docs, along with the caveat that its widely quoted memory figure is a Kubernetes/CRI-O measurement that does not transfer to Docker unchanged.
Fixed¶
container_ttlnow actually reclaims anything. The sweep was written, tested and reported byGET /policy— and the dashboard rendered it as "reclaimed n after stop" — but nothing outside the tests ever called it: the periodic loop only swept workspaces. An operator settingpersist_containers=True, container_ttl=3600was told their builds were being reclaimed while stoppedsandboxd-*containers accumulated until a session was closed withpurge. The loop now runs both passes, and runs them on the worker pool, because deleting a directory tree and asking the daemon to list and remove containers both block. A service given an injectedsandbox_builderis not asked for a daemon it may not have.- A malformed token is a
401, not an unauthenticated500. Header values reach a handler latin-1 decoded, so a client is free to send a byte above 127 — andsecrets.compare_digestrefuses a non-ASCIIstroutright rather than returningFalse. One such byte inX-Sandbox-Tokentherefore raisedTypeErrorout of the dependency on every authenticated endpoint, before any authorization ran. Tokens are now compared as bytes. The line was covered; the input class was not. - Inspecting a session reaped mid-request no longer returns
500.describeread its record out of the service's own dict having trusted the authorization check — butGET /sessions/{id}?usage=trueawaits a Docker stats call in between, which takes 1–2 seconds, and the idle reaper runs on its own timer. The record could be gone by the time it was read, raisingKeyError. It is looked up again and reported as404, which is what actually happened. A listing takes one such sample per sandbox, so it now drops the vanished row instead of failing the operator's whole view for one reaped session. - Starting and stopping a sandbox no longer blocks the event loop.
SessionManager.get_or_createcalledsandbox.start()inline, which for a cold runtime pulls or builds an image — seconds to minutes, during which no other session's command could run, on a service whose entire purpose is serving several tenants at once. Both lifecycle calls now run on a thread pool via the newSessionManager(executor=...), whichsandboxdpoints at its own worker pool rather than asyncio's shared default one.DELETE /sessions/{id}?purge=truelikewise stopped discarding a container and deleting a directory tree on the loop. - Two requests naming one session id no longer both get it. With the container start now suspending, both could pass the "is this id taken?" check, both were handed the same sandbox, and the second overwrote the first's record with a freshly minted token — silently invalidating the token the first caller was holding, including for its own
stop(), and never producing the documented409. The id is claimed before the first await, so the loser is told. The per-tenant ceiling counts opens still in flight for the same reason: counting only registered sessions let a concurrent burst walk straight past a limit none of them had registered against yet. - One dangling symlink no longer breaks a whole archive listing.
ln -s missing.txt report.mdinside a sandbox resolves to a path inside the workspace, so containment passed and only thestatfailed — turning the directory's listing into a404whose message additionally carried the resolved host path. Untrusted code in the container can plant one. Such an entry is now omitted, exactly as one pointing out of the workspace already was. RemoteSandboxdegrades on a success status carrying the wrong body. The class promises that failures are returned and never raised, but every operation calledmodel_validate(response.json())unguarded, and_ensure_sessioncaught onlyRuntimeError— so an auth proxy or captive portal answering200 text/htmlin front of the service raised out of a tool call and ended the agent run. Every parse now goes through one helper that treats an unusable body as a failed operation. Asession_idortenantthe service would reject is validated when the sandbox is constructed rather than out of whichever tool call happens to open the session.- The workspace sweep survives a directory vanishing under it.
is_dir()followed bystat()leaves a window in which a concurrent?purge=truedeletes the directory between the two calls — reachable now that both run on the same worker pool rather than being serialised by the event loop — and the unguarded second call aborted the whole pass over one entry. One guardedstatdoes both jobs. DockerSandboxis actually measured. The class carried a blanket# pragma: no cover, so the 272 statements that hold the Docker socket —read,write,edit,execute, container creation and reattachment — contributed nothing to the 100% gate: they were exercised only by@pytest.mark.dockertests, which CI deselects. The pragma is gone and those paths are now covered against a fake daemon. Writing the fake surfaced thatget_archivemust return a generator rather than any iterator, which is exactly what the earlyclose()on an oversized file depends on."pass"is likewise gone from the coverage exclusions, where it was hiding anyexcept: passfrom the report.- Shutdown stops sessions concurrently. Each stop waits for the process inside to die, and doing that one session after another turned a full pool's teardown into minutes — long enough for an orchestrator to lose patience and kill the process halfway. One uncooperative sandbox no longer strands the rest either; it is logged and the others still stop.
- A stopped
RemoteSandboxis usable again.stop()closes the HTTP client it owns, and every later operation then reported the service as unreachable — for ever, and untruthfully. The client is rebuilt on the nextstart(), which is the behaviourDockerSandboxalready had: stopping it does not retire the object, it just ends the session. A client supplied by the caller is still never closed. - The dashboard's markup is read from disk once rather than on every request to
/ui. -
ReadRequest.offsetandlimitare bounded. A negative offset was not rejected and did not fail; it sliced from the end of the file and quietly returned the wrong lines. -
Eviction can no longer interrupt a running command. A candidate was chosen by
last_activity, which is stamped when a command begins — so a command still running after a minute looked a minute idle and could be evicted mid-flight, killing an agent's work to serve somebody else's first request. The service now counts operations in flight per session, through the same wrapper that writes the activity log, and a session with any is never a candidate. That is also what makesevict_idle_after=0meaningful: "evict anything not actually running something" rather than "evict anything that started something a while ago". GET /sessions?usage=trueno longer blocks the whole service. One Docker stats call takes 1–2 seconds — the endpoint waits for a second sample before it can report a CPU rate — and the listing made one per sandbox, sequentially, on the event loop. With twelve sessions that is a twelve-to-twenty-second request during which no agent's command could run, and the dashboard polls it every three seconds with sampling on by default. Samples are now taken concurrently on the service's worker pool and cached forUSAGE_CACHE_SECONDS, so a poll costs one round trip's latency regardless of session count and repeated polls cost nothing.describetakes an already-taken sample rather than deciding to fetch one, because the decision belongs where the concurrency is.- A runtime edit no longer orphans its previous image for ever.
image_tag_forembeds a digest of theRuntimeConfig, so changing one package mints a new tag and left the old image on disk with nothing to reclaim it — a few hundred megabytes per edit, accumulating silently, and the same shape of leak as the workspaces thatworkspace_ttlnow sweeps. A build prunes the images it supersedes, skipping any still backing a running container, since a session opened before the edit is legitimately using it. - A runtime allowlist entry is now a
SandboxRuntime, with its own ceilings. The allowlist mapped an alias to a bare image string, so every sandbox on a service ran under one memory and CPU limit — and one number is wrong for a whole service: a notebook-style data runtime needs several gigabytes where a plain shell needs a few hundred megabytes, and forcing one value on both either starves the first or over-commits the host for the second. An entry now carriesmem_limit,cpus,pids_limitandnetwork_modeof its own, so one runtime can be given four gigabytes and another can be the only one allowed to reach the network. A ceiling left unset takes the service-wide value, which is a default rather than a maximum — what bounds the host ismax_sessionstimes the largest runtime ceiling. A bare string still works wherever an entry is expected. The client's side of this is unchanged: a request still names an alias and nothing else. - An entry may also name a
RuntimeConfig(or a built-in runtime by name) instead of a ready-made image, sosandboxdcan serve environments whose packages are built into an image on first use. Until now it could only run images that already existed. The service'swork_diris forced onto a built runtime, because the workspace volume, the archive endpoints and a client's paths must not end up disagreeing about where files live. SandboxBuildertherefore receives(session_id, SandboxRuntime)rather than(session_id, image), andSandboxdConfig.resolve_imageis nowresolve_runtime.ServicePolicy.runtimesis a list ofRuntimePolicycarrying each alias's effective ceilings, because the number an operator needs is the one actually in force, not the one before the override.DEFAULT_RUNTIMESis what a service allows when its operator names nothing —pythonandnode, both ready-made and without ceilings of their own, because a default that built package sets would make a fresh deployment's first session take minutes.SUGGESTED_RUNTIMESis a fuller catalogue to adopt or copy from, opt-in because every entry is a commitment on the operator's own host.SUGGESTED_RUNTIMESsizes the analytics runtime for what it actually needs, and says why in the config. Measured on one 188 MB CSV with the sameGROUP BY: pandas peaks at 570 MB and is killed outright under a 384 MB ceiling; DuckDB answers in 312 MB and Polars survives the ceiling too, both roughly eight times faster. Sopython-analyticsnow carries a 1 GB ceiling rather than 4 GB, which roughly doubles how many analysis sandboxes fit in a fixed amount of RAM, whilepython-datasciencekeeps its 4 GB because that is pandas' appetite rather than the task's. Both stay in the catalogue — most model-written code reaches for pandas first — but the descriptions now steer the choice.- Eight more built-in runtimes (
BUILTIN_RUNTIMESgoes from 5 to 13):python-analytics(DuckDB, Polars, PyArrow),python-scraping,python-documents,node-typescript,bun,deno,goandrust. Each answers a task agents are actually given; the four new ready-made images add no build step at all. - A remote sandbox opens its session on the first operation, not on construction. Building a
RemoteSandboxnow performs no I/O at all, so an agent granted a sandbox it never uses costs no session, no container and not even a round trip — which is what makes it reasonable to grant the capability to an agent that only might need files. Opening is guarded, so two operations arriving together on the adapter's thread pool open one session rather than racing each other into a409, and a failed open degrades like any other operation instead of ending the run.start()remains, for pre-warming a sandbox before a latency-sensitive turn. GET-free workspace browsing:POST /workspaces/{id}/lsand/read, with theWorkspaceArchiveclient. These read the service's host volume directly, so listing the files a conversation produced last week costs no container start and works for a session reaped long ago — previously the only way to see them was through a live sandbox, which meant booting a container and reaping it again minutes later. Service token only: a reaped session has no token of its own left, and the intended caller is an application proxying file views to its users after applying its own authorization. UnlikeRemoteSandboxthese raise rather than degrade, because no model is waiting on them and an application answering "show me my files" must be able to tell "there are none" from "the service is misconfigured";WorkspaceArchiveError.status_codecarries what the service said.- Reading the host filesystem makes path containment the whole game, so both ways in are closed by resolving first and checking after:
..in a requested path, and a symlink the sandbox itself planted — untrusted code in a container can runln -s /etc/shadow notes.txt, which points at the container's own file from inside but would resolve to the host's when followed from outside. A path resolving outside the workspace is refused even when the link sits inside it, and such an entry is omitted from a listing rather than reported. - Paths are relative to the sandbox's work directory, and an absolute in-container path resolves to the same file, so a UI can hand back exactly the path a live listing showed it. Files the agent wrote outside the work directory are not on the volume, and so not in the archive.
SandboxdConfig(persist_containers=True)gives each sandbox a container name derived from its session, so a reaped session is stopped rather than discarded and the next attach restarts the same filesystem.workspace_rootalone preserves only the work directory —pip installandapt-get installwrite outside it, so an agent working in what is meant to be "the same machine" reinstalled its dependencies after every idle timeout. Off by default, because stopped containers then accumulate until a session is purged; and a service setting rather than a request field, because an agent author choosing this would be choosing the host's disk consumption.DELETE /sessions/{id}?purge=trueandRemoteSandbox.stop(purge=True)discard the session's container and its host workspace, for when the thing it belonged to is gone — a deleted conversation, a departed user. A plain close still keeps both, which is what lets a later attach find the same files.SandboxdConfig(workspace_ttl=...)sweeps workspace directories no session has opened for that long, on the same interval as idle reaping. Without it, every workspace ever created stayed on disk for the life of the deployment:workspace_roothad no reclamation of any kind, and nothing but an operator's cron job would have removed a workspace belonging to a conversation deleted months ago. The clock runs from when a session was last opened, so a long-running session is never swept for being old.SandboxdConfig(max_sessions_per_tenant=...)withCreateSessionRequest.tenant(andRemoteSandbox(tenant=...)).max_sessionsalone caps the service, which is no help when one application serves many tenants — the busiest one fills the pool and every other tenant gets429. The label is declared by the client rather than parsed out of the session id, so the service imposes no id convention; it is capacity accounting that grants and authorizes nothing, since only a holder of the service token can open a session at all. Reported back inGET /sessionsandGET /policy.- A sandbox may now outlive the run that created it.
CreateSessionRequest.reuse(andRemoteSandbox(reuse=True)) attaches to the session already open under asession_idinstead of failing, which is what makes a per-conversation sandbox possible at all: a secondRemoteSandboxnaming an open id previously got409, andstart()turns any 4xx intoRuntimeError. The attaching caller is handed the token the session already has, rather than a fresh one that would cut off whoever holds it. Aruntimedisagreeing with the open session is refused — honouring it would mean replacing a live sandbox and discarding the files the caller came back for. SandboxdConfig(workspace_root=...)mounts{workspace_root}/{session_id}/workspaceinto each sandbox, so a session's files survive its container. Without it they live only in the container's write layer, and an idle reaping between two turns of one conversation discards them silently. Session ids are pattern-checked before they reach a path, so one cannot traverse out of the root.SessionManager(on_release=...)fires with a session id just after its sandbox is stopped, byreleaseand therefore by idle cleanup too. For a caller keeping per-session state of its own, this is the only notice a reaping happened — pollingsessionswould mean discovering it late, or never.create_console_toolset(backend=...)andConsoleCapability(backend=...). The console tools readctx.deps.backendthrough theConsoleDepsprotocol, which no host owning its own deps type can satisfy — a platform that assembles agents from configuration has its own deps class and should not grow abackendfield for one capability. With an explicit backend the capability carries it and the deps type stops mattering, which is also what lets a single agent hold one particular sandbox.- Remote sandboxes over HTTP (
src/pydantic_ai_backends/remote/). An application can now use sandboxes that live in another process, so it never needs Docker access itself — the point being that a containerised app which mounted/var/run/docker.sockto start sandboxes would be handing itself host root, and nesting Docker in Docker to avoid that is worse. remote/wire.py— the HTTP contract as Pydantic models, one source of truth for both sides. The operation endpoints keep the field namesKubernetesPodSandbox(mode="http")already sends (/exec,/read,/write,/ls,/glob), which until now existed only as hand-builtjson={...}dicts;/edit,/grep,/existsand/read_bytesare additions. Binary payloads travel base64-encoded, so a non-UTF-8 file survives a round trip (the existing/read-basedread_bytescould not).RemoteSandbox— client implementing the same synchronous surface asDockerSandbox, so it drops intoSessionManageror a console toolset unchanged. Operations degrade rather than raise on transport failure, matchingLocalBackend/DockerSandbox; onlystart()raises, because a caller that cannot get a sandbox at all needs to know why. Needs the newremoteextra (httpx).sandboxd(remote/server.py, newserverextra) — a FastAPI service owning Docker, with session lifecycle, pooling, idle reaping, capacity backpressure (429),GET /sessionswith optionaldocker statssampling, per-session inspection and an unauthenticated/healthz. Blocking sandbox calls run on the service's own thread pool rather than asyncio's shared default one.- Security model: clients choose nothing about the container. Image, mounts, network mode and every resource ceiling come from
SandboxdConfig; a request carries at most a runtime alias validated against a server-side allowlist. Session ids are pattern-checked so they cannot traverse a directory. Each session gets its own token, and the token is verified before existence is revealed so an unauthenticated caller cannot enumerate session ids by watching 401 turn into 404. - The
sandboxddashboard is now three views rather than one crowded page. Everything shared one screen, which left the session detail — terminal included — squeezed into a narrow column beside a table it was competing with for width, while the page below it sat empty. - Sessions carries capacity and the table at full width, now showing each session's
tenant, and can open a session with a runtime, an id and a tenant. - Workspace is one session at full width: a terminal about three times its old size with history recall and a clear action, a two-pane file browser that puts the preview beside the listing, the activity log and an info panel.
- Runtimes & policy shows the allowlist as cards — image, description, memory, CPU, processes, network, and whether the first session builds an image — beside the service defaults and the retention settings. The new-session form names the ceilings of whichever runtime is picked, so an operator is not guessing what a choice costs.
- The file browser reads the live sandbox by default and can switch to the stored workspace, which is served from the host volume: reading a stopped session live restarts its container, and the archive needs no container at all. The distinction is stated in the pane rather than left to be discovered.
- Rewritten against the house CSS and HTML standards — oklch tokens driven by one hue, cascade layers, logical properties, a container query for the file split,
:focus-visiblerings, aprefers-reduced-motionguard, real<button>s for every control and completerole="tab"/aria-selected/aria-controlswiring on both tab strips. Still one self-contained file with no build step and no CDN, andtests/test_ui.pynow pins that, along with every element lookup in the script resolving and every tab having a panel. - An optional dashboard for
sandboxdat/ui, enabled withSandboxdConfig(ui_enabled=True)(off by default). One self-contained HTML file — no build step, no npm, no CDN — served straight from the package, so it works offline and behind a strict CSP. Lists live sessions with idle time and memory against each sandbox's ceiling, shows the policy in force, and can open, inspect and terminate sessions. Each session gets a workspace view with four tabs: - Terminal — scrollback, colour-coded exit codes, and
↑/↓command-history recall. Not a PTY: the protocol is request/response, so this runs one command per submission rather than holding an interactive shell. - Files — breadcrumb navigation over
/lswith a file preview via/read. - Activity — the session's operation log, so an operator can watch what an agent is doing rather than only their own clicks.
- Info — created/idle timestamps and sampled resource usage.
The token is held in sessionStorage rather than localStorage, since a root-equivalent credential should not outlive the browser session. The HTTP API is unchanged whether or not the UI is on; the page is static and every call it makes is authenticated exactly like any other client's.
- A per-session activity log, exposed at GET /sessions/{id}/events?after=<seq> for incremental polling. Every file and command operation records what was addressed, whether it succeeded, a short outcome summary and a duration — recorded even when the operation raises, which is the case an operator most wants to see. Payloads are deliberately never stored: an audit trail holding file contents or command output would be a data leak that also grows without bound. The log is a bounded ring buffer per session (200 entries) and targets are truncated, so a long-lived session cannot grow the service's memory. Authorization matches the operation endpoints, so a session token reads only its own log.
- GET / now describes the service instead of returning a bare 404, listing the mounted endpoints (derived from the app, so the list cannot go stale), the docs URL and the dashboard URL when enabled.
- GET /policy (service token) reports the ceilings and image allowlist actually in force, so an operator can read the limits off the running service rather than inferring them from a config file.
- DockerSandbox.resource_usage() and the SandboxUsage type (sandbox.py, types.py). Samples memory, CPU percent and process count from a single non-streaming stats() call, so a session can be inspected without reaching into the container object. CPU is None when the daemon reports no previous sample to compute a rate against.
DockerSandboxresource limits (src/pydantic_ai_backends/backends/docker/sandbox.py). Containers previously ran with no ceiling of any kind, so a single agent could exhaust the host:mem_limit(Docker syntax, e.g."512m") also pinsmemswap_limitto the same value — without a matching swap ceiling the kernel lets a container over its memory limit swap instead, starving the host.cpus(in cores, e.g.1.5) maps tonano_cpus.pids_limitdefaults to512, bounding a runawayforkloop. PassNoneto disable. Note that once a container hits the ceiling its processes stay alive, so further commands fail until they exit.security_opt=["no-new-privileges:true"]is now always applied, denying sandboxed code the one cheap escalation route a container leaves open. Verified not to affectpip.-
max_read_bytes(default 8 MiB) caps whatread/read_bytes/editwill pull out of a container. -
SessionManager(max_sessions=...)(src/pydantic_ai_backends/backends/docker/session.py). Once the ceiling is reached,get_or_createraises the newSessionLimitExceededfor new session ids instead of starting an unbounded number of containers. Existing live sessions are still served at the cap. Uncapped by default. AsyncBackendAdapter(..., executor=...)andensure_async(..., executor=...)(src/pydantic_ai_backends/adapter.py). Blocking backend calls previously always went to asyncio's default thread pool, which holdsmin(32, cpu_count + 4)workers (14 on a 10-core host) and is shared with everything else in the process — so a handful of concurrentnpm install-length commands filled it and unrelated reads and writes queued behind them. Passing a dedicated pool isolates sandbox work.ensure_asyncis idempotent on adapters, so wrapping once and passing the adapter around makes one pool serve every call site.DockerSandbox.stop(remove=True)(sandbox.py) deletes the container and its write layer. Named containers deliberately survive a plainstop()— reuse across restarts is the point ofcontainer_name— but until now nothing could remove one at all.
Changed¶
- Internal restructuring for readability, no change to the public API. Every name in
pydantic_ai_backends.__all__still imports from the same place; what moved is private. The library grew by accretion — five backends each carrying their own copy of path normalisation, string replacement, text decoding and output caps, with modules reaching into each other's underscore-prefixed helpers. That duplication is now shared: - New private modules hold what several backends need:
_editing.py(theeditreplacement rules and their wording),_paths.py(virtual path normalisation and validation),_text.py(encoding detection, decoding and PDF extraction, lifted out ofDockerSandboxmethods),_limits.py(the output and read ceilings that four modules each defined separately),_optional.py(every optional-extra import, so a missing dependency always names the extra that provides it). backends/docker/sandbox.pyis down from 1071 to ~540 lines: the shared Docker client moved to_client.py, Dockerfile generation and image resolution to_image.py, andstats()parsing to_stats.py.backends/base.pyno longer exports lazy-import helpers or extension sets for other modules to import through it.LocalBackendcomposes rather than accumulates: the background-process registry is nowbackends/_background.pyand the synchronous permission logic — including the "ask with nobody to ask" reconciliation — isbackends/_guard.py. This drops the reach intoPermissionChecker._find_matching_rulefrom another module.toolsets/console.pyis down from 1082 to ~630 lines, with tool text indescriptions.py, image/document handling in_content.py, read-fingerprint tracking in_tracking.pyand ruleset interpretation in_ruleset.py.CompositeBackendandAsyncCompositeBackendshare onePrefixRouterinstead of two copies of the routing and root-aggregation logic.- Private helpers that other modules imported are now public where they belong (
glob_to_regex,matches_pattern,PermissionChecker.find_matching_rule,is_ignored_path,shell_argv,deny_rules), and comments that restated the code were deleted in favour of names, types and docstrings that carry the meaning. Rationale comments were kept only where they explain a decision the code cannot. SessionManagerreads a documented sandbox surface instead of private attributes.BaseSandboxnow exposeslast_activityandtouch(), andDockerSandboxexposesidle_timeout; the manager prefers these and still falls back to_last_activity/_idle_timeout, so customsandbox_factorysandboxes written against the old contract keep working.- Every backend's
editnow reports the same two failures the same way:String '<old>' not found in fileandString '<old>' found N times. Use replace_all=True to replace all, or provide more context.DockerSandbox,DaytonaSandboxandKubernetesPodSandboxpreviously omitted the string, and Kubernetes worded the second case differently. - Glob patterns in permission rules are compiled once and cached. Every
check_syncwalked a ruleset's rules and rebuilt a regex for each one, so a read againstDEFAULT_RULESETrecompiled twelve patterns. create_ruleset(default=...)is typedPermissionActionrather thanstr, which removed eight# type: ignore[arg-type]suppressions frompresets.py.- Idle sandbox cleanup no longer dies on the first failure (
session.py).start_cleanup_loop's body had no exception guard, so one raise — an unreachable daemon, or a custom-factory sandbox missing the private_last_activitystamp — killed reaping permanently and near-silently, leaving every later container to accumulate. Failures are now logged and retried on the next tick; cancellation still propagates. cleanup_idlehonours each sandbox's ownidle_timeout(session.py), falling back to the manager'sdefault_idle_timeout.DockerSandboxhas always accepted and documentedidle_timeoutbut nothing ever read it. An explicitcleanup_idle(max_idle=...)still overrides every sandbox.cleanup_idletolerates sandboxes without an activity stamp (session.py) instead of raisingAttributeErroron the private attribute of a duck-typed object; such sandboxes are simply never reaped.is_alive()caches the daemon's answer for 5 seconds (sandbox.py). It does areload()round trip andSessionManager.get_or_createcalls it on every request, so each agent turn was billed a round trip just to confirm liveness.- A sandbox that fails during
start()is stopped and not registered (session.py). It was previously dropped on the floor while possibly holding a created container that nothing would ever clean up. Internedasyncio.Lockentries for sessions that were rejected or failed to start are now pruned duringcleanup_idle, rather than accumulating one per failure. - One Docker client per process instead of one per sandbox (
sandbox.py)._ensure_container()calleddocker.from_env()for every sandbox; that negotiates the API version with a blockingGET /version(~8.5 ms measured) and builds arequests.Sessionwith its own connection pool which was never closed, so every session pinned a socket pool for as long as its container object lived. The client is now shared, and rebuilt after a fork — its pooled sockets must not be used from two processes at once, and web/task servers routinely fork workers after import. - Encoding detection now samples a 32 KiB prefix (
sandbox.py).chardetis pure Python and linear in input size, so it ran over whole files: detection on a 4.4 MB file took 7.2 s and now takes 54 ms (135× faster) with the same verdict. Files are still decoded in full, and the binary-file heuristic still inspects the whole text. - Oversized files are refused instead of buffered into host memory (
sandbox.py).read_bytesconcatenated the tar stream and then copied it through a secondBytesIO, holding several copies of the file at once, with no upper bound — reading 20 lines of a 500 MB log transferred all 500 MB. The payload now accumulates directly into the buffertarfilereads from, and a file overmax_read_bytesis rejected using the size Docker reports in a response header, before any content crosses the socket.readandeditreport the limit and suggest reading a slice withexecute();read_byteskeeps its documented empty-bytes contract. execute()discards output beyond the cap before decoding (sandbox.py), rather than decoding the whole payload and then truncating it — this doubled peak memory on commands likecat big.log. The 100 000 cap is now measured in bytes rather than characters._build_runtime_image()passesusedforsecurity=Falsetohashlib.md5(); the digest only tags a cache image, and plainmd5()is unavailable on FIPS-enforcing hosts.
Fixed¶
- A session reaped for idleness no longer leaks its bookkeeping (
remote/server.py).SessionManager.cleanup_idledropped the sandbox but nothing told the service, so the_Sessionrecord — its token and its whole 200-entry event log — stayed for the life of the process,GET /sessions/{id}answered from a record with no sandbox under it, and the newreusepath would have attached to one. The service now registerson_releaseand forgets the session with it. ConsoleCapability(edit_format="hashline")now registershashline_edit. The format only ever reachedget_instructions(), so the injected prompt told the model to callhashline_editwhile the toolset registerededit_file— an agent configured for hashline editing could not edit anything.StateBackend.read_bytesreturnsb""for an unsafe path instead of the error message encoded as bytes, which a caller could not tell from real file content beginning withError:. This matchesLocalBackendand the documented contract.KubernetesPodSandbox.editno longer carries a dead branch checkingread_bytesfor ab"[Error: ...]"sentinel.BaseSandbox.read_byteshas returnedb""on failure for some time, so the sentinel could not occur and the guard only pinned behaviour that reality never produced.backends/kubernetes.pyno longer carries# noqa: WPS433directives for a rule this project does not configure (wemake-python-styleguide), which maderuff checkfail with fiveRUF100errors on a clean checkout.- The Kubernetes tests no longer leave a fake
httpxinsys.modules(tests/test_kubernetes_sandbox.py). The stub was installed at module import time and never removed, so every test module collected afterwards saw the fake instead of the real library.KubernetesPodSandboximportshttpxlazily, so the stub is now scoped to that module with a fixture.
[0.2.16] - 2026-07-18¶
Fixed¶
- Permission rules are now enforced on every content-returning path of
LocalBackend(closes #62) (src/pydantic_ai_backends/backends/local.py). Previously onlyread/write/editand the execute command-pattern check consulted the ruleset, so adenyrule like**/restricted/**could be bypassed: read_bytesnow applies the same "read" rules asread(a denied path returnsb"") — this also closes the leak through the console toolset'sread_fileon images/documents, which reads viaread_bytes.grep_rawno longer returns matches from files denied for "grep" or "read" (grep leaks content, so read denies must apply), and an explicit "grep" deny on the search path errors the search.ls_info/glob_infohide entries and matches with an explicit "ls" / "glob" deny. Listings can't prompt, so "ask" is treated as visible — a ruleset whose global default is "ask" keeps listing as before.execute/async_execute/execute_backgroundgain a best-effort path guard: path-looking tokens in the command are resolved against the backend root and denied when they hit a "read"/"write" deny rule, catching the straightforwardcat restricted/secret.txtbypass. Documented explicitly as defense-in-depth, not a security boundary — useDockerSandbox(or an execute default of "deny"/"ask") for enforced isolation. The permissions docs gained a section spelling out these semantics.
[0.2.15] - 2026-06-27¶
Added¶
AsyncCompositeBackend— async path-prefix routing across mixed sync/async backends (#57, extends #55) (src/pydantic_ai_backends/backends/composite.py). An async counterpart toCompositeBackend: routes file operations to sub-backends by path prefix (longest match wins), wrapping sync sub-backends viaensure_async()internally, and aggregating rootls_info/glob_info/grep_rawacross routes. Constructor acceptsBackendProtocol | AsyncBackendProtocolfor bothdefaultandroutes. Exported from the package root.- Background (long-lived) process support for
LocalBackend(#58) (src/pydantic_ai_backends/backends/local.py,src/pydantic_ai_backends/protocol.py,src/pydantic_ai_backends/types.py). Lets dev servers, watchers, and other long-running commands outlive a singleexecute()call (which kills its whole process tree on timeout): LocalBackend.execute_background()/read_background()/kill_background()/list_background()/kill_all_background()— spawn a detached process (start_new_session=True), spool stdout/stderr to temp files, drain incrementally by byte offset, and tear down whole process groups withkillpg.- New runtime-checkable
BackgroundSandboxProtocolandAsyncBackgroundSandboxProtocol(extending the existing sandbox protocols, which are left untouched), plusAsyncBackgroundSandboxAdapterandensure_async()routing to it. - New console tools
run_in_background/read_output/kill_shell/list_shells, gated behindinclude_background(defaultTrue) andinclude_execute. - New
BackgroundHandle/BackgroundOutput/BackgroundProcessInfodataclasses, exported from the package root. - Image downscaling on read (#58) (
src/pydantic_ai_backends/toolsets/console.py; optionalimagesextra).read_fileresizes images whose longest edge exceeds 1568px (aspect preserved, re-encoded) before returningBinaryContent, so large screenshots don't waste tokens or exceed provider image limits. Pillow-optional — a graceful no-op when Pillow is absent or the image is already small.
Changed¶
readoutput ceiling (#58) (src/pydantic_ai_backends/backends/local.py). A singlereadis bounded at 200k chars: a default read over the cap is truncated to a page with a notice, while an explicitoffset/limitthat still overflows returns an error so the agent narrows its request instead of flooding context.glob_infoorders results by modification time (newest first) (#58) — with a path tie-break, instead of alphabetically by path — usually what an agent wants.edit_filestaleness guard (#58) (src/pydantic_ai_backends/toolsets/console.py).read_file/write_filerecord a content fingerprint;edit_filerefuses with a "read it again" error when a previously-read file changed on disk since it was read. Files never read through the tools are unaffected, and the fingerprint is re-recorded after a successful edit so consecutive edits work. (hashline_editkeeps its own per-line hash check.)- Python
grepfallback skips build/cache directories (#58) (node_modules,__pycache__,dist,build,.venv, caches, …) whenignore_hiddenis on (the default);ignore_hidden=Falsesearches everything. ripgrep mode already honors.gitignore.
[0.2.14] - 2026-06-22¶
Added¶
- Async backend adapter support (#55, closes #54) (
src/pydantic_ai_backends/adapter.py,src/pydantic_ai_backends/protocol.py). Lets consumersawaitbackend I/O uniformly, whether the underlying backend is sync or natively async: - New runtime-checkable
AsyncBackendProtocolandAsyncSandboxProtocoldescribing the async file/sandbox surface. - New
AsyncBackendAdapter/AsyncSandboxAdapterwrapping a syncBackendProtocol/SandboxProtocol, delegating each call viaasyncio.to_thread.AsyncSandboxAdapter.execute()prefers a nativeasync_execute()when present, otherwise offloadsexecute()to a thread. - New
ensure_async()helper that returns native async backends untouched, is idempotent on already-wrapped adapters, and wraps sync backends (selecting the sandbox adapter when the backend exposesexecute). - All five names (
AsyncBackendProtocol,AsyncSandboxProtocol,AsyncBackendAdapter,AsyncSandboxAdapter,ensure_async) are exported from the package root.
Fixed¶
AsyncBackendAdapter.read_bytes()prefers publicread_bytes()over private_read_bytes()(#54). Wrapper backends such as pydantic-deep'sBranchOverlayexpose a publicread_bytes()but may not implement_read_bytes, so the adapter now uses the public method when available and only falls back to_read_bytesfor existing backends — avoiding anAttributeErroronread_bytes().
Changed¶
create_console_toolsetroutes all backend I/O throughensure_async()(src/pydantic_ai_backends/toolsets/console.py). The console tools (ls,read_file,write_file,edit_file,hashline_edit,glob,grep,execute) now callawait ensure_async(backend).<op>()instead ofasyncio.to_thread(backend.<op>, ...), so a natively async backend is awaited directly while sync backends keep their thread-offload behavior. Theexecute_enabledgate is still read from the unwrapped backend, and per-path edit locks remain keyed on the raw backend.
[0.2.13] - 2026-06-17¶
Fixed¶
LocalBackend.write()/edit()no longer double carriage returns on Windows (#51) (src/pydantic_ai_backends/backends/local.py).Path.write_text()opens in text mode, where only\nis translated toos.linesepon write while existing\ris left untouched — so content already containing\r\n(commonly emitted by LLMs) became\r\r\non Windows, leaving files with blank lines between every line of code. Content is now normalized before writing so text mode re-adds clean, platform-native line endings.
[0.2.12] - 2026-06-12¶
Added¶
KubernetesPodSandbox— run the agent's shell tools inside a Kubernetes pod (#46) (src/pydantic_ai_backends/backends/kubernetes.py). A newBaseSandboximplementation with synchronous methods (matchingDockerSandbox/DaytonaSandbox), usable as a drop-in for anySessionManagerconsumer.start()creates the pod and waits for it to becomeReady;stop()deletes it. Two execution modes:mode="http"(default) talks to an in-pod HTTP exec server onport— recommended for long-running tool calls (npm install, headless browser, MCP servers).mode="api"uses the K8spods/execsubresource (needspods/execRBAC on the caller; fine for short commands). Requires/bin/shand atimeoutbinary in the image.
Exported as KubernetesPodSandbox from the package root (lazy import; requires the optional kubernetes extra).
[0.2.11] - 2026-06-07¶
Added¶
read_filecan return PDFs asBinaryContentfor document understanding (#48) (src/pydantic_ai_backends/toolsets/console.py). Previouslycreate_console_toolset'sread_filereturned raster images (png/jpg/jpeg/gif/webp) aspydantic_ai.BinaryContentunderimage_support, but PDFs fell through to the text path and were read via theawk-basedBaseSandbox.read, which on a binary PDF emits an empty string — soread_file("report.pdf")returned""instead of usable content. Documents are now handled as a separate, independent content kind from images:- New
document_support: bool = Falseandmax_document_bytesparameters oncreate_console_toolset(default off → fully backward compatible;image_support/max_image_bytesunchanged). - New exported constants, kept disjoint from the image ones:
DOCUMENT_EXTENSIONS({"pdf"}),DOCUMENT_MEDIA_TYPES({"pdf": "application/pdf"}), andDEFAULT_MAX_DOCUMENT_BYTES(50 MB). Whendocument_support=True, reading a PDF returnsBinaryContent(media_type="application/pdf")so capable models (OpenAI/Anthropic/Gemini) can read it directly. - Internally,
read_file(bothedit_formatvariants) now delegates to two clearly-named helpers —_maybe_image_contentand_maybe_document_content— over a shared_read_binary_within_limit(not-found/empty + size-limit guards), removing the prior duplication between the tworead_filedefinitions while keeping the image and document seams independent for future per-kind handling (e.g. OCR for images vs. native document understanding / text extraction for PDF/DOCX).
[0.2.10] - 2026-06-01¶
Changed¶
- Docstring and import hygiene (internal; no behavior change). Converted reStructuredText-style double-backtick inline code in docstrings and comments to single-backtick Markdown (108 occurrences), so it renders correctly under the mkdocstrings Markdown handler. Hoisted 12 function-local imports to module top where safe; the optional-dependency
daytonaanddocker.errorsimports were intentionally left local (they must not load when those extras are absent), along with conditional and circular-import-avoidance imports.
Security¶
- Dockerfile generation now validates and escapes untrusted runtime values -
RuntimeConfigpackage names, environment variable names/values, setup commands, andwork_dirwere interpolated directly intoRUN/ENV/WORKDIRlines with no validation, so a value likefoo; rm -rf /could execute arbitrary commands during image build. Package names are now checked against a strict allowlist regex (supporting npm scoped names like@types/react), env var names follow the POSIX portable character set, env values andwork_dirareshlex-quoted, and setup commands / env values containing newlines or shell metacharacters are rejected withValueError. - Glob negated character class
[!...]is no longer mistranslated in permission matching -_glob_to_regexcopied a glob character class verbatim, so glob negation[!a](meaning "any char except a") became regex[!a](matching the literal!ora) - the exact opposite, silently inverting deny/allow rules that used negated classes. A leading!/^after[is now emitted as regex[^...].
Fixed¶
BaseSandbox.read()reported wrong line numbers whenoffset > 0- thesed | cat -npipeline renumbered the slice from 1; it now usesawkso line numbers reflect real file positions (matchingStateBackend/LocalBackend).BaseSandbox.write()corrupted content via heredoc escaping - the body was pre-escaping\,$, and backtick even though the heredoc delimiter is quoted (no shell expansion), doubling backslashes and inserting literal\$/\`. The escaping is removed so content is written verbatim.BaseSandbox.glob_info()double-quoted the path and never matched basename globs - the alreadyshlex-quoted path was re-wrapped in single quotes, and-path '{pattern}'matched the whole pathname so patterns like*.pynever matched. The path is now quoted once and the pattern is prefixed (-path '*/{pattern}').- npm runtime packages were installed globally and unimportable -
node-reactand other npm runtimes rannpm install -g, so libraries likereact/react-domwere not resolvable from a project'snode_modules. They are now installed locally into thework_dir. SessionManager.get_or_create()race could create duplicate sandboxes - the unguarded check-then-create allowed two concurrent calls for the samesession_idto each create and start a sandbox, leaking one. A per-sessionasyncio.Locknow serializes creation.hashlineedits silently ignored a range wheninsert_after=True-end_line/end_hashwere validated but then ignored by the insert branch. The combination is now rejected with a clear error so callers are not misled.- Empty files were reported as "not found" - the console
read_file(hashline) andhashline_edittools usedif not raw_bytes, treating a legitimately empty file (b"") as missing. They now usebackend.exists(path)to distinguish missing from empty. BaseSandbox.read_bytes()/DaytonaSandbox.read_bytes()returned an error sentinel as file bytes - on failure they returned[Error: ...]-encoded bytes, indistinguishable from a real file beginning with[Error:. They now returnb""on failure (matching the other backends), andDaytonaSandbox.edit()usesexists()to detect missing files instead of sniffing the sentinel.CompositeBackend.grep_raw()swallowed search errors - when aggregating from root, error strings from the default backend and all string results from routed backends were dropped, so an invalid regex looked like "no matches". The first error encountered is now propagated.DockerSandbox.execute(timeout=0)ran unbounded -if timeout:treated0likeNone; it now usesif timeout is not None:.DockerSandbox._decode_unknown_text()had nondeterministic decode order - when chardet detected an encoding, the candidates were stored in aset, so iteration order (detected vs utf-8) was unspecified. It now uses an ordered, deduplicated list with the detected encoding first.DockerSandbox.write()ignored theput_archiveresult - aFalsereturn (e.g. target is not a directory) was treated as success. It now returns aWriteResult(error=...).DockerSandbox.__del__could raise during interpreter shutdown - the teardown is now wrapped in a broadcontextlib.suppress, and the explicitstop()lifecycle is documented as the reliable path.StateBackend.grep_raw()missed an explicitly named hidden file - withignore_hidden=True, a directly requested hidden path (e.g./.env) fell into the directory branch and matched nothing. An explicitly named file is now looked up in the full file set; the hidden filter applies only to directory walks.- Renamed the custom
PermissionErrortoPermissionAskErrorto stop shadowing the builtinPermissionError(anOSErrorsubclass) for importers of the permissions module.PermissionErrorremains as a deprecated subclass alias for backward compatibility. create_console_toolsetdocstring corrected -max_image_bytesnow documents the real 50MB default (was 10MB).write_fileline count corrected - the tool reportedcontent.count("\n") + 1, which said "1 lines" for empty content and overcounted content ending in a newline. It now useslen(content.splitlines()).
Documentation¶
- Documentation accuracy pass. Rewrote the broken
SessionManagerexample in the multi-user guide to use the real async API (get_or_create/release/shutdown,default_runtime/workspace_root) and corrected its API-reference members (create_session/get_session/end_sessiondid not exist). Added aDaytonaSandboxAPI page and documented the[daytona]install extra, replaced the deprecatedPermissionErrorwithPermissionAskErrorin the permissions reference, fixed invalid Docker runtime keys (python→python-minimal) and the incorrectDockerSandboxworkspace_rootclaim, added a hashline edit-format section to the console-toolset guide, and expanded the capability page. Resolved a duplicateRuntimeConfigrender somkdocs build --strictpasses with zero warnings.
[0.2.9] - 2026-05-24¶
Infrastructure¶
- CI: bump
astral-sh/setup-uvtov8.1.0acrossci.yml(×3) andpublish.yml— pulled in from Renovate's Dependency Dashboard #41 (rate-limited there). Pinned to the specific patch becauseastral-sh/setup-uvdoes not maintain a rollingv8tag — onlyv8.0.0/v8.1.0exist (v7and earlier do have rolling majors). - CI: bump
actions/setup-pythontov6indocs.yml— same source as above;v6has a rolling tag so plain@v6is used.
No source-code changes — pure CI / dependency-bot housekeeping. Library behaviour unchanged from 0.2.8.
[0.2.8] - 2026-05-24¶
Added¶
BackendProtocol.exists(path) -> boolpredicate (#37) — first-class way to check file presence without sniffing private state (e.g.StateBackend._files) or pattern-matching empty-byte returns fromread_bytes(). Contract: returnsTrueonly for paths that exist as regular files; directories, missing paths, permission errors, and OS-rejected paths (e.g. embedded null bytes) all returnFalse. Implementations across every backend:StateBackend— dict membership after_validate_path/_normalize_path.LocalBackend—Path.is_file()after_validate_path; catchesPermissionError,ValueError(POSIX rejects embedded null bytes at the syscall boundary), and residualOSError(ELOOP, name too long, ...) to honour the "False for invalid paths" promise.CompositeBackend— one-line delegation to_get_backend(path).exists(path).BaseSandbox(Docker inherits via default) —test -f <quoted-path>over the sandbox shell with a 5 s ceiling.DaytonaSandbox— nativeself._sandbox.fs.get_file_info(path); broadexcept Exceptionmatches the file's existing pattern (mirrorsread_bytes/write); returnsFalseon any failure or whenis_diris true.
Changed¶
- ⚠️ Renamed
_read_bytes→read_bytes(#37) — promotes bytes-reading from private (leading underscore) to public onBackendProtocol. The semantics are unchanged (empty bytes for missing/erroring reads —exists()is now the way to distinguish a real empty file from a missing one), but the rename is breaking for any caller that was reaching for the private_read_bytesname directly (e.g. earlier versions of the console toolset'sread/hashline_edittools, which are updated in the same release). - Console toolset's
executetool now prefersbackend.async_execute(...)when available (#37) — wires up the async-cancellable execution path added in 0.2.7. Backends that don't exposeasync_executecontinue to use the existingasyncio.to_thread(backend.execute, ...)fallback, so third-party implementations are unaffected. hashline_editis now serialized per(backend, path)(#37) — concurrent edits to the same file no longer race read-modify-write. Uses a module-levelweakref.WeakKeyDictionary[backend, dict[path, asyncio.Lock]]so locks are garbage-collected with the backend.
Infrastructure¶
renovate.json(#38) — Renovate config landed (first auto-PRs already produced #39/#40).- CI: bump
actions/checkouttov6(#40, Renovate auto-PR). - CI: bump
docs.ymlPython to3.14(#39, Renovate auto-PR). Theci.ymltest matrix stays at["3.10", "3.13"].
[0.2.7] - 2026-05-14¶
Added¶
LocalBackend.async_execute()— async, cancellable shell execution (#36, related to pydantic-deepagents#93) — usesasyncio.create_subprocess_execso that cancelling the calling task immediately kills the subprocess instead of waiting for the thread to finish. The console toolset'sexecutetool now prefersbackend.async_execute(...)when available and falls back toasyncio.to_thread(backend.execute, ...)for backends that don't expose the new method, so third-party backend implementations are unaffected.- On Unix, the subprocess is launched with
start_new_session=Trueand cancellation/timeout callsos.killpg(proc.pid, SIGKILL)so the entire process tree (including grandchildren the shell forked, e.g.sh -c "sleep 60") is reaped. Windows relies oncmd /clifecycle to terminate child processes. - Cleanup
await proc.communicate()afterkill()is wrapped inasyncio.shieldso a second cancellation can't leave subprocess pipes dangling. -
Output is decoded with
errors="replace"to tolerate non-UTF-8 bytes. -
Cross-platform shell selection in
LocalBackend(#36) — new static helperLocalBackend._shell_cmd(command)returns["cmd", "/c", command]on Windows and["sh", "-c", command]elsewhere. Bothexecute()andasync_execute()route through it.
Fixed¶
-
[WinError 2]crash on Windows when callingLocalBackend.execute()(#36) — the execute path hardcoded["sh", "-c", command], which is not available on Windows. Now routes through_shell_cmd()and usescmd /conwin32. -
Agent task cancellation didn't reach the running subprocess (#36) — previously,
execute()ran on a worker thread viaasyncio.to_thread, so cancelling the calling task only marked the future as cancelled while the subprocess kept running until completion or timeout. Withasync_execute(), cancellation propagates through toproc.kill()(orkillpgon Unix) immediately. -
timeout=0was silently rewritten to 120 seconds (#36) —execute()usedtimeout or 120, which treated0as falsy and substituted the default. Now uses an explicitNonecheck so0is honoured (will trigger immediate timeout).
Changed¶
- Extracted
MAX_EXECUTE_OUTPUT = 100_000constant inlocal.py, shared by bothexecute()andasync_execute()truncation paths.
[0.2.6] - 2026-05-05¶
Fixed¶
CompositeBackendroute matching with trailing slashes — paths without trailing slashes (e.g./foo) now correctly match routes registered as/foo/, matching shell semantics (ls /tmpequalsls /tmp/). Previously, LLM agents querying paths without trailing slashes would silently fall through to the default backend, breaking file discovery. Added_normalize_path()static method and tightened matching to exact-or-child semantics (== prefix or startswith(prefix + "/")) to also prevent false positives (e.g./foobarno longer matches/foo/). (#34, by @pawelkiszczak, closes #33)DockerSandbox.executeoutput handling — fixed crash whenexec_runreturns a generator instead ofbytesby joining the iterator before decoding.
[0.2.5] - 2026-04-20¶
Fixed¶
- Globstar support in
BaseSandbox.glob_info— replacedfind -namewithfind -pathso patterns like**/*.mdmatch nested files. Previously sandbox backends silently returned empty results for globstar patterns, breaking callers that rely on recursive discovery (e.g. pydantic-deep's skills toolset). Behavior now aligns withStateBackend. (#32, by @ilayu-blip)
[0.2.4] - 2026-04-11¶
Added¶
container_nameparameter onDockerSandbox— stable Docker container name for reuse across restarts. When set,_ensure_container()looks for an existing container with that name and reattaches (running containers are reused, stopped containers are restarted). Impliesauto_remove=Falseso installed packages, caches, and filesystem state persist between sessionssandbox_factoryparameter onSessionManager— accepts aCallable[[str], Any]to create sandboxes of any type (Docker, Daytona, or custom). WhenNone, falls back to the defaultDockerSandboxbehavior (fully backward compatible). ExportedSandboxFactorytype alias- Lifecycle methods on
BaseSandbox—start(),is_alive(),stop(), and_last_activitytracking added to the base class so all sandbox types support session management out of the box start()method onDaytonaSandbox— no-op (Daytona sandboxes auto-start on creation), added forSessionManagercompatibility- Activity tracking on
DaytonaSandbox—_last_activityupdated onexecute()calls for idle session cleanup
Changed¶
SessionManageris now backend-agnostic — no longer hardcoded toDockerSandbox. Works with any sandbox that hasstart(),stop(),is_alive(), and_last_activity. Type hints changed fromDockerSandboxtoAnyfor generic usage
[0.2.3] - 2026-04-06¶
Changed¶
- Async-safe console toolset — All synchronous
BackendProtocolcalls in the console toolset are now wrapped inasyncio.to_thread(), preventing them from blocking the async event loop. Affectsls,read_file,write_file,edit_file,glob,grep, andexecutetools. TheBackendProtocolitself remains synchronous — no changes required for existing backend implementations. (#26, by @pedroallenrevez)
[0.2.2] - 2026-03-31¶
Changed¶
- Bump minimum
pydantic-ai-slimto>=1.74.0for compatibility with asyncget_instructionson toolsets
[0.2.1] - 2026-03-28¶
Added¶
network_modeparameter onDockerSandbox— Controls container network access. Passnetwork_mode="none"to disable networking entirely, or"bridge","host","container:<name|id>"for other modes. Defaults toNone(Docker default). (#24, by @ggozad)
[0.2.0] - 2026-03-28¶
Added¶
ConsoleCapability— new pydantic-ai capability that bundles console tools + instructions + permission enforcement:- Registers all tools automatically (ls, read_file, write_file, edit_file, glob, grep, execute)
- Injects console system prompt
- Fixes #23:
READONLY_RULESETnow actually blocks writes —prepare_toolshides denied tools from the model entirely,before_tool_executechecks per-path permissions
Fixed¶
create_console_toolsetwithREADONLY_RULESETnow actually blocks writes — previouslywrite=denyin a ruleset only setrequires_approval=False(because"deny" != "ask"), so tools were registered normally and the agent could write freely. Now tools for denied operations are removed from the toolset entirely. (#23, reported by @dj-passey)
Changed¶
- Minimum pydantic-ai version bumped to
>=1.71.0(capabilities API support)
[0.1.14] - 2026-03-11¶
Fixed¶
- DockerSandbox: relative paths and missing file errors —
read(),write(), andedit()now resolve relative paths against the container'swork_dirinstead of/. Missing files return clean"Error: File '...' not found"messages matchingLocalBackendbehavior. (#22, by @ret2libc) - Fix
test_read_bytes_nonexistent_pathassertion — Test incorrectly assertedresult is Noneinstead ofresult == b"", matching the actual_read_bytes()return value.
[0.1.13] - 2026-02-26¶
Added¶
- Custom tool descriptions —
create_console_toolset()now acceptsdescriptions: dict[str, str] | Noneparameter to override any tool's built-in description
[0.1.12] - 2026-02-25¶
Added¶
DaytonaSandbox— cloud sandbox backend powered by Daytona ephemeral sandboxes. Sub-90ms startup, no Docker daemon required. Install withpip install pydantic-ai-backend[daytona].execute()via Daytona SDKsandbox.process.exec()_read_bytes()andwrite()use native Daytona file download/upload APIs (more efficient than shell for binary and large files)edit()via read → Python string replace → write (same pattern asDockerSandbox)is_alive(),stop(), automatic cleanup via__del__- Auth:
DAYTONA_API_KEYenvironment variable orapi_key=constructor parameter - Configurable
work_dir(default:/home/daytona) andstartup_timeout - New
[daytona]optional dependency group:daytona-sdk>=0.9.0
Changed¶
- Extracted
BaseSandboxtobackends/base.py—BaseSandboxis no longer defined insidebackends/docker/sandbox.py. It now lives in its own module (pydantic_ai_backends.backends.base) since it's not Docker-specific. All existing import paths (from pydantic_ai_backends import BaseSandbox,from pydantic_ai_backends.backends.docker import BaseSandbox) remain fully backward compatible.
[0.1.11] - 2026-02-24¶
Changed¶
- Moved tool-specific guidance from system prompt to tool descriptions — Each console tool (
ls,read_file,write_file,edit_file,glob,grep,execute) now carries detailed usage guidance directly in itsdescriptionparameter via exported constants (LS_DESCRIPTION,READ_FILE_DESCRIPTION,WRITE_FILE_DESCRIPTION,EDIT_FILE_DESCRIPTION,GLOB_DESCRIPTION,GREP_DESCRIPTION,EXECUTE_DESCRIPTION, plus hashline variantsHASHLINE_READ_FILE_DESCRIPTION,HASHLINE_EDIT_DESCRIPTION). This follows the pattern used by Claude Code and deepagents where guidance lives closest to the tool context. - Slimmed
CONSOLE_SYSTEM_PROMPTandHASHLINE_CONSOLE_PROMPT— Reduced from ~35 lines to 5 lines each. Shell usage rules, git safety, dependency management, debugging tips, and security guidance now live inEXECUTE_DESCRIPTION. Edit best practices (surgical edits, re-read after edit) moved toEDIT_FILE_DESCRIPTION. File creation rules moved toWRITE_FILE_DESCRIPTION. - All description constants are exported from
pydantic_ai_backendsandpydantic_ai_backends.toolsetsfor external customization and override.
[0.1.10] - 2026-02-20¶
Changed¶
- Stronger tool preference language in system prompts — Changed "ALWAYS prefer specialized tools" to "You MUST use specialized tools" in both
CONSOLE_SYSTEM_PROMPTandHASHLINE_CONSOLE_PROMPT. Models now receive a stronger directive to useread_file,glob,grepetc. instead of shell equivalents likecat,find,grep. - Stronger execute tool description — Changed "Do NOT use it for file operations" to "You MUST avoid using file operation commands in the shell" with each tool preference bullet prefixed with "You MUST use". Reduces unwanted
cat/grep/findusage in shell. - Re-read after edit guideline — Added "After editing a file, re-read it before making subsequent edits" to both
CONSOLE_SYSTEM_PROMPTandHASHLINE_CONSOLE_PROMPTfile operations best practices. Prevents stale-read bugs when auto-formatters or pre-commit hooks modify files on disk after an edit.
[0.1.9] - 2026-02-20¶
Added¶
- Hashline edit format — alternative to
str_replacethat tags each line with a 2-character content hash. Models reference lines bynumber:hashpairs instead of reproducing exact text, eliminating whitespace-matching errors and reducing output tokens. Inspired by Can Bölük's hashline research which showed +5 to +64pp accuracy improvement across 16 models. edit_formatparameter oncreate_console_toolset()— set to"hashline"to opt in (default:"str_replace")edit_formatparameter onget_console_system_prompt()— returns matching system prompt- When
edit_format="hashline":read_filereturns lines as1:a3|content(number:hash|content)hashline_edittool replacesedit_file— reference lines by number+hash, no old-text reproduction needed- Operations: replace single line, replace range, insert after, delete
- Hash validation: edit rejected if file changed since last read
- New
pydantic_ai_backends.hashlinemodule with pure utility functions:line_hash()— generate 2-char hex content hash for a lineformat_hashline_output()— format file content with hashline tagsapply_hashline_edit()— apply a hashline edit with hash validationapply_hashline_edit_with_summary()— same but returns human-readable summary
HASHLINE_CONSOLE_PROMPT— system prompt for hashline modeEditFormattype alias exported from package
[0.1.8] - 2026-02-19¶
Fixed¶
DockerSandbox.grep_raw()searched entire filesystem by default: When nopathargument was provided,grep_raw()defaulted to"/"instead of".", causing grep to scan the entire container filesystem. This made pathless grep calls extremely slow (minutes) and returned irrelevant matches from system files. Now defaults to the current working directory. (#13)
[0.1.7] - 2025-02-16¶
Added¶
- Image support in
read_file: Whenimage_support=Trueis passed tocreate_console_toolset(), reading image files (.png,.jpg,.jpeg,.gif,.webp) returns aBinaryContentobject that multimodal models can see, instead of garbled text. image_supportparameter oncreate_console_toolset()(default:False)max_image_bytesparameter to limit image file size (default: 50MB)IMAGE_EXTENSIONS,IMAGE_MEDIA_TYPES,DEFAULT_MAX_IMAGE_BYTESconstants exported from the package- Documentation: Expanded guides for backends, console toolset, permissions, and multi-user setups.
[0.1.6] - 2025-02-07¶
Added¶
max_retriesparameter forcreate_console_toolset(): Allows configuring the maximum number of retries for all console tools (write_file,edit_file,read_file,ls,glob,grep,execute). When the model sends invalid arguments (e.g. missing a required field likecontentforwrite_file), the validation error is fed back and the model can self-correct up tomax_retriestimes. Defaults to 1 (unchanged) for backward compatibility. (pydantic-deepagents#25)
[0.1.5] - 2025-01-28¶
Changed¶
DockerSandbox.read()now supports any file extension instead of a hardcoded whitelist. Uses a three-tier approach: known extensions → mimetypes detection → binary detection fallback. Binary files return[Binary file - cannot display as text]instead of raising an error. (#9)
Fixed¶
DockerSandbox.stop()and__del__now handle edge cases where_containerattribute may not exist, preventingAttributeErrorduring cleanup.
[0.1.4] - 2025-01-22¶
Changed¶
- README: Complete rewrite with centered header, badges, Use Cases table, and vstorm-co branding
- Documentation: Updated styling to match pydantic-deep pink theme
Added¶
- Custom Styling: docs/overrides/main.html, docs/stylesheets/extra.css
- Abbreviations: docs/includes/abbreviations.md for markdown expansions
- FAQ Section: Expanded getting-help.md with common questions
[0.1.3] - 2026-01-22¶
Fixed¶
DockerSandbox.edit()now handles multiline strings correctly. Replaced sed/grep-based implementation with Python string operations, which naturally handle newlines and special characters without shell escaping issues. (#6)
Changed¶
- Added
edit()as an abstract method inBaseSandboxto make the interface explicit - Docker tests now use shared fixtures (
scope="module") for faster test execution
[0.1.2] - 2026-01-21¶
Added¶
- Fine-grained Permission System - Pattern-based access control for file operations and shell execution
- Pre-configured Permission Presets (DEFAULT, PERMISSIVE, READONLY, STRICT)
- Permission Integration with
LocalBackendandcreate_console_toolset()
Fixed¶
DockerSandbox.execute()no longer incorrectly escapes commands when timeout is specified.
[0.1.1] - 2026-01-20¶
Added¶
ignore_hiddenparameter togrep_raw()inBackendProtocol
[0.1.0] - 2025-01-17¶
Added¶
- Initial release —
LocalBackend,StateBackend,CompositeBackend,DockerSandbox,SessionManager, Console Toolset
[0.0.4] - 2025-01-16¶
Added¶
volumesparameter toDockerSandboxworkspace_rootparameter toSessionManager
[0.0.1] - 2025-12-28¶
Added¶
- Initial release extracted from pydantic-deep