fix: enforce a ruleset's paths, unify stop, serve workspace bytes, and let an operator change the ceilings - #99
Merged
Conversation
Ending a sandbox is one idea and it had three spellings:
`RemoteSandbox.stop(purge=False)`, `DockerSandbox.stop(remove=False)`, and
`DaytonaSandbox.stop()` / `KubernetesPodSandbox.stop()` / `BaseSandbox.stop()`
taking nothing at all. A caller holding "a sandbox" could not call it
without knowing which one it had:
>>> stop = getattr(sandbox, "stop")
>>> stop(purge=True)
TypeError: DaytonaSandbox.stop() got an unexpected keyword argument 'purge'
The failure was quiet in the worst possible place. Teardown is
best-effort and normally sits inside a broad `except`, so the `TypeError`
was swallowed and logged - and the call that would have released the
resource was the one that raised. In AgenticOS that meant a Daytona
sandbox was never deleted on any path: once per run, on the customer's
own cloud account, until somebody read the bill.
`purge` is now the name everywhere, and `False` the default everywhere -
the default matters as much as the name, since `stop()` is what a turn
ending calls and it must not discard files the next turn is meant to
find. Daytona and Kubernetes accept it and document that it makes no
difference to them: neither keeps a filesystem this library can reattach
to, so stopping *is* deleting, and saying so beats ignoring the argument
in silence.
Not a breaking change, deliberately. `stop()` still works everywhere it
did, and `DockerSandbox.stop(remove=...)` still works and warns - this is
not the release to break somebody's teardown over a keyword. It is the
only place the old name is allowed to appear, and a test pins that.
`tests/test_sandbox_stop_contract.py` asserts the shape rather than any
one backend's behaviour, so a backend added later fails the moment it
invents a fourth spelling.
Closes #98
`WorkspaceArchive` could only `read`, and `read` returns `str`. So the
archive was unusable for the commonest thing an agent actually puts in a
workspace: a chart, a rendered PDF, an image it fetched. Decoding those
as text and re-encoding produces a corrupt file that nonetheless
downloads successfully - the worst available outcome - so a consumer had
to allowlist text suffixes and refuse everything else.
Which meant the container backend, the one you would use for real work,
was the one whose outputs could not be fetched.
`read_bytes` on the client, `POST /workspaces/{id}/read_bytes` on the
service, and `read_workspace_bytes` doing the filesystem half. It reuses
the `ReadBytesRequest`/`ReadBytesResponse` pair the live-session route
already had, so the wire gains no new shape.
Beside `read` rather than replacing it: text wants the slicing, the line
numbers and the encoding detection that `read` does, and a PNG wants none
of it. Whole rather than sliced, because a byte range means nothing for
the formats this exists for - and `ls` already carries `size`, so a
caller who wants to bound a read can look first.
The service's `max_read_bytes` still applies, and that is the ceiling
that matters here: `read_bytes` returns a whole file, so without it a
caller could ask the daemon to hold an arbitrarily large one in memory on
their behalf.
Tests cover the issue's own acceptance criterion - the first four bytes
come back `\x89PNG` - plus the two refusals worth having on a route that
reads the host volume directly: a path escaping the workspace, and a file
over the ceiling.
Closes #96
…ults
A `PermissionRuleset` handed to `ConsoleCapability` or
`create_console_toolset` reached exactly two things: `requires_approval`,
for the write and execute approval flags, and `_denied_tools`, which drops
a tool whose *operation* defaults to `"deny"`. Nothing read
`OperationPermissions.rules`.
So the shape a caller writes when they want "allow the workspace, deny
credentials and the system tree" - every operation `default="allow"` with
the patterns in `rules` - was no enforcement at all:
read_file /.env -> 'OPENAI_API_KEY=sk-live-secret'
read_file /etc/passwd -> 'root:x:0:0'
write_file /sub/.env -> 'Wrote 1 lines to /sub/.env'
grep PASSWORD -> matched /credentials.txt, with the line
which is worse than rejecting the ruleset, because it looks like a working
boundary. `PermissionChecker` answered correctly the whole time; it was
never asked.
`PermissionGuard` has been here since `LocalBackend` needed it, and only
`LocalBackend` ever used it. `GuardedBackend` puts it on any backend, and
`create_console_toolset` applies it in `backend_for` - the one place every
tool resolves its backend, which is what makes the cover total rather than
a list of tools somebody has to remember to extend. `guarding` answers with
the backend untouched when there is no ruleset or when the backend already
enforces one of its own, so every existing caller behaves exactly as before.
`grep_raw` is filtered rather than refused: it takes a tree, a pattern over
`/` legitimately spans the workspace, and `GrepMatch` carries the matching
*line* - so an unfiltered grep hands over the contents of the very files
the rules protect, by a different tool, with no refusal anywhere. `ls` and
`glob` filter on their own rules and deliberately not on `read`, matching
`LocalBackend`; the docstring says so, and a test pins it, because a
listing that hid a file `exists` reports would send an agent rewriting one
it cannot read.
`execute` goes through `execute_denial_reason`, so `cat /etc/passwd` is
caught. Defence in depth and not a boundary - a shell reaches files in ways
string inspection cannot see.
Two things found writing it:
`command_path_targets` resolved a path before matching, and `resolve()`
follows symlinks - so on macOS `/etc/passwd` became `/private/etc/passwd`
and a rule reading `/etc/**` matched nothing. Any symlinked directory does
the same on any platform. Both forms are matched now, which can only deny
more, never less.
`execute` is reached through `__getattr__` rather than declared, and that
is not style. The toolset asks `hasattr(backend, "execute")` to decide
whether to answer "Backend does not support command execution"; a declared
method would make that true for a `StateBackend`, turning a friendly answer
into a raise.
Closes #97
`GET /policy` reported the ceilings actually in force, which was enough to show an operator what they were and no way to change any of them. An operator running `sandboxd` for several teams had about thirty knobs reachable only through the environment - and a restart drops every resident sandbox, so raising one memory ceiling ended every conversation on the host. Both shapes the issue asked about, because they answer different questions. `PUT /policy` is what an operator screen can sit on; `SANDBOXD_POLICY_OVERRIDES` is the same thing for a deployment that would rather not expose a write endpoint at all. **One source of truth with two entrances, not two paths.** Both call `apply_policy_update`, which is the only place that validates and the only place that writes, so neither can put the service in a state the other cannot describe. Two implementations of "what may change" would have drifted, and the one that drifted wider would be the security hole. Ceilings and lifetimes are writable. What is *not* writable is the point, and each refusal has a reason: `runtimes` membership names an image, `network_mode` is an escape rather than a ceiling, `oci_runtime` is the same one level lower, `sandbox_uid` of 0 is root, `work_dir` is where the workspace volume mounts and the archive reads, and `persist_containers`/`prewarm` are startup shape. `CreateSessionRequest` carries no container settings because a process holding the Docker socket can start a privileged container that mounts the host, and that reasoning does not stop applying because the caller holds the service token: the token is held per tenant by an application, and an organization's administrator is not the person who runs the host. `extra="forbid"`, so sending one of those is a 422 naming the field rather than a key quietly dropped - an operator who tries to widen the network is told no instead of believing they have. An unknown alias is a 400: refused, never created. Absent means "leave it alone", which is why every field is optional. `None` is a meaningful value for most of these - it pins swap to memory, or takes the daemon's default - so a model that could not tell absent from null would wipe a ceiling every time somebody changed a different one. It takes effect without a restart because the sandbox builder reads these when it builds rather than capturing them at startup. A sandbox already resident keeps what it was created with, since Docker sets the ceiling on the container; that is documented rather than worked around. `runtimes` is replaced rather than mutated. It is declared `Mapping` deliberately, `SandboxRuntime` is frozen, and swapping the whole mapping means a reader already holding one sees a consistent snapshot instead of a half-applied update. mypy caught the first version writing into it. A malformed overrides file is logged and ignored: an operator who mistypes a ceiling should get a log line and the previous value, not a daemon that will not boot and takes every resident sandbox with it. A file deleted after being read is left alone rather than reverted - reverting means keeping a second copy of the environment's values, and the honest reading of a deleted file is "stop overriding from here", which is a restart. `POLICY_SCALARS` and the wire model are two lists on purpose, so widening the wire shape is not the same act as widening what gets written, and a test fails if somebody does one without the other. Closes #95
Merged
DEENUU1
added a commit
that referenced
this pull request
Aug 4, 2026
Cuts 0.2.25 for the four issues merged in #99 (#95, #96, #97, #98). **The one worth reading is #97.** A `PermissionRuleset` handed to `ConsoleCapability` had its per-path rules ignored entirely — so a caller who wrote out `**/.env`, `**/*.pem` and `/etc/**` got no enforcement, and a boundary that looked like it worked. Anyone relying on that shape should take this release. #98 changes a signature and does not break one: `purge` is the name on every backend's `stop` now, and `DockerSandbox.stop(remove=...)` still works with a `DeprecationWarning`. A caller that never passed either is unaffected. #96 and #95 are additive — `WorkspaceArchive.read_bytes`, and `PUT /policy` plus `SANDBOXD_POLICY_OVERRIDES` for changing ceilings without dropping every resident sandbox. Version and changelog only, which is the shape every release commit here has. 1664 tests, 100% coverage, ruff/pyright/mypy clean on every supported Python — verified on #99 before merge and again on this branch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #95, #96, #97, #98 — the four open issues, one commit each.
#97 — a ruleset's per-path rules reached nothing
A
PermissionRulesethanded toConsoleCapabilityreachedrequires_approval(the approval flags) and_denied_tools(drop 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" was no enforcement at all:Which is worse than rejecting the ruleset, because it looks like a working boundary.
PermissionCheckeranswered correctly the whole time; it was never asked.PermissionGuardhas been here sinceLocalBackendneeded it and onlyLocalBackendever used it.GuardedBackendputs it on any backend, applied inbackend_for— the one place every tool resolves its backend, which is what makes the cover total rather than a list somebody has to remember to extend.guardingreturns the backend untouched when there is no ruleset or when it enforces one of its own, so every existing caller behaves exactly as before.grep_rawis filtered rather than refused:GrepMatchcarries the matching line, so an unfiltered grep hands over the contents of the very files the rules protect.ls/globfilter on their own rules and deliberately not onread, matchingLocalBackend— documented, with a test pinning it.Two things found writing it:
command_path_targetsresolved a path before matching, andresolve()follows symlinks — so on macOS/etc/passwdbecame/private/etc/passwdand a rule reading/etc/**matched nothing. Any symlinked directory does the same anywhere. Both forms are matched now, which can only deny more.executeis reached through__getattr__rather than declared. The toolset askshasattr(backend, "execute")to decide whether to answer "Backend does not support command execution"; a declared method would make that true for aStateBackend, turning a friendly answer into a raise.#98 —
stophad three signaturesRemoteSandbox.stop(purge=False),DockerSandbox.stop(remove=False), andDaytonaSandbox.stop()/KubernetesPodSandbox.stop()/BaseSandbox.stop()taking nothing. A caller holding "a sandbox" could not call it:Quiet in the worst place — teardown sits in a broad
except, so the call that should have released the resource was the one that raised. In AgenticOS that meant a Daytona sandbox was never deleted on any path, once per run, on the customer's own cloud account.purgeeverywhere, defaulting toFalseeverywhere — the default matters as much as the name, sincestop()is what a turn ending calls and must not discard files the next turn expects. Daytona and Kubernetes accept it and document that it makes no difference to them.Not breaking:
stop()works everywhere it did, andDockerSandbox.stop(remove=...)still works and warns.test_sandbox_stop_contract.pyasserts the shape, so a backend added later fails the moment it invents a fourth spelling.#96 — the archive could only read text
WorkspaceArchive.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 and refuse everything an agent is most likely to have produced — so the container backend, the one you would use for real work, was the one whose outputs could not be fetched.read_byteson the client,POST /workspaces/{id}/read_byteson the service, reusing theReadBytesRequest/ReadBytesResponsepair the live-session route already had. The service'smax_read_bytesstill applies, and that is the ceiling that matters: it returns a whole file. Tests cover the issue's own criterion (first four bytes\x89PNG) plus the two refusals worth having on a route that reads the host volume — a path escaping the workspace, and a file over the ceiling.#95 — no way to change the ceilings without a restart
Both shapes the issue offered, because they answer different questions.
PUT /policyis what an operator screen sits on;SANDBOXD_POLICY_OVERRIDESis the same thing for a deployment that would rather not expose a write endpoint.One source of truth with two entrances, not two paths. Both call
apply_policy_update— the only place that validates, the only place that writes. Two implementations of "what may change" would drift, and the one that drifted wider would be the security hole.Ceilings and lifetimes are writable. The refusals are the point:
runtimesmembershipnetwork_modehostis not a ceiling, it is an escapeoci_runtimesandbox_uidwork_dirpersist_containers,prewarmCreateSessionRequestcarries no container settings because a process holding the Docker socket can start a privileged container that mounts the host, and that reasoning does not stop applying because the caller holds the service token: the token is held per tenant by an application, and an organization's administrator is not the person who runs the host.extra="forbid", so one of those is a 422 naming the field rather than a key quietly dropped. An unknown alias is a 400 — refused, never created. Absent means "leave it alone", becauseNoneis meaningful for most of these and a model that could not tell absent from null would wipe a ceiling every time somebody changed a different one.A change applies to the next sandbox; Docker sets the ceiling on the container, so one already resident keeps what it was created with. Documented rather than worked around.
A malformed overrides file is logged and ignored — an operator who mistypes a ceiling should get a log line, not a daemon that will not boot and takes every resident sandbox with it. A deleted one is left alone rather than reverted, since reverting means keeping a second copy of the environment's values.
Verified
1664 tests pass, 100% coverage,
ruff,pyrightandmypyall clean.Worth saying where the verification reaches and where it does not: the remote tests drive the real ASGI app through
TestClient, so the routes, the wire models and the archive are exercised for real. The Docker and Daytona paths are fakes, as they were before —test_sandbox_stop_contract.pyis written against the real classes' signatures precisely because a fake cannot catch a signature drift.Three things deliberately not done:
runtimesmembership stays read-only. The issue asked for "the runtime allowlist and per-alias ceilings", and the allowlist cannot be writable without letting a caller name an image — which the same issue asks to reject. Ceilings per alias are writable; the aliases are not.ls/globstill show the name of a file whose contents are denied. Changing that is a behaviour change forLocalBackendtoo, and a listing that hid a fileexistsreports would send an agent rewriting one it cannot read.execute_denial_reasoncatches path arguments, and that is defence in depth — real isolation is the container's.