Skip to content

fix: enforce a ruleset's paths, unify stop, serve workspace bytes, and let an operator change the ceilings - #99

Merged
DEENUU1 merged 4 commits into
mainfrom
feat/sandboxd-policy-and-guards
Aug 4, 2026
Merged

fix: enforce a ruleset's paths, unify stop, serve workspace bytes, and let an operator change the ceilings#99
DEENUU1 merged 4 commits into
mainfrom
feat/sandboxd-policy-and-guards

Conversation

@DEENUU1

@DEENUU1 DEENUU1 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Closes #95, #96, #97, #98 — the four open issues, one commit each.

#97 — a ruleset's per-path rules reached nothing

A PermissionRuleset handed to ConsoleCapability reached requires_approval (the approval flags) and _denied_tools (drop 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" 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, applied in backend_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. guarding returns 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_raw is filtered rather than refused: GrepMatch carries the matching line, so an unfiltered grep hands over the contents of the very files the rules protect. ls/glob filter on their own rules and deliberately not on read, matching LocalBackend — documented, with a test pinning it.

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 anywhere. Both forms are matched now, which can only deny more.
  • execute is reached through __getattr__ rather than declared. 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.

#98stop had three signatures

RemoteSandbox.stop(purge=False), DockerSandbox.stop(remove=False), and DaytonaSandbox.stop() / KubernetesPodSandbox.stop() / BaseSandbox.stop() taking nothing. A caller holding "a sandbox" could not call it:

TypeError: DaytonaSandbox.stop() got an unexpected keyword argument 'purge'

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.

purge everywhere, defaulting to False everywhere — the default matters as much as the name, since stop() 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, and DockerSandbox.stop(remove=...) still works and warns. test_sandbox_stop_contract.py asserts the shape, so a backend added later fails the moment it invents a fourth spelling.

#96 — the archive could only read text

WorkspaceArchive.read returns str, 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_bytes on the client, POST /workspaces/{id}/read_bytes on the service, reusing the ReadBytesRequest/ReadBytesResponse pair the live-session route already had. The service's max_read_bytes still 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 /policy is what an operator screen sits on; SANDBOXD_POLICY_OVERRIDES is 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:

Refused Because
runtimes membership Adding an alias means naming an image
network_mode host is not a ceiling, it is an escape
oci_runtime The same, one level lower
sandbox_uid 0 is root
work_dir Where the workspace volume mounts and the archive reads
persist_containers, prewarm Startup shape, not ceilings

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 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", because None is 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, pyright and mypy all 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.py is written against the real classes' signatures precisely because a fake cannot catch a signature drift.

Three things deliberately not done:

  • runtimes membership 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/glob still show the name of a file whose contents are denied. Changing that is a behaviour change for LocalBackend too, and a listing that hid a file exists reports would send an agent rewriting one it cannot read.
  • Commands are not filtered by pattern. execute_denial_reason catches path arguments, and that is defence in depth — real isolation is the container's.

DEENUU1 added 4 commits August 4, 2026 18:54
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
@github-project-automation github-project-automation Bot moved this to Triage in Vstorm OSS Aug 4, 2026
@DEENUU1
DEENUU1 merged commit 7e15c75 into main Aug 4, 2026
15 checks passed
@DEENUU1
DEENUU1 deleted the feat/sandboxd-policy-and-guards branch August 4, 2026 17:18
@DEENUU1 DEENUU1 mentioned this pull request Aug 4, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

No way to read or change a service's ceilings per runtime from a client

1 participant