Skip to content

plimsoll v0.22.0

Choose a tag to compare

@carrollco carrollco released this 19 Oct 21:47
· 26 commits to main since this release

This release adds a Kubernetes provider (a provider is the backend that runs the code): agentsandbox runs each snippet or project in a pod claimed from a Kubernetes agent-sandbox warm pool, and reads the isolation tier (the boundary a run is reported to have run behind: vm, kernel, container or process) from the pod's runtime class instead of taking it from configuration, with Kata Containers as a vm tier. It also ships an MCP server, so an AI coding assistant can run code through plimsoll and get a checked run record back (a run record is the daemon's statement, by SHA-256 digest, of what was sent, what came back and where it ran, which the client recomputes before it accepts the answer); a shell-and-files workspace for Python agent frameworks, with adapters for Claude Managed Agents and the OpenAI Agents SDK; and ways to check one line of a signed record file on its own and to sign your own statements about checked runs. On docker, guest code no longer sees the host's resolver settings, and an error shows a caller only text plimsoll wrote. The wire protocol is unchanged (protocol 3), so v0.21.0 clients and daemons work with this release; read Upgrading for what a deployment can notice.

Upgrading

  • Docker: every container now mounts a file from the daemon's host. plimsolld writes a fixed /etc/resolv.conf (one loopback nameserver, no search domain, the suffix a resolver appends to short names, which can name a private network) into a private directory under the system temp directory, named plimsoll-docker-config-*, and every container mounts it read-only. If the file is deleted, container starts fail; if it is deleted or changed, Preflight (the readiness check /readyz runs) fails until a restart. The directory holds a BSD lock for the process's life, which systemd-tmpfiles honours; a temp-directory cleaner that ignores such locks must skip that prefix.
  • The text of some errors changed; their codes and reasons did not. A deadline now reads "the deadline passed before the call finished" and a cancellation "the call was canceled before it finished", with an error ID when the original text said more. Text a provider did not compose itself (part of a typed refusal, a session end's detail) is replaced by an error ID, and the full text is in the daemon's log under it. Code that matches message text should match the RPC status code, the not-dispatched reason (the mark that says nothing ran, and why) or the session-end reason instead.
  • The capsule example asks for three flags on any bundle but the published one. go run ./examples/capsule -bundle FILE now needs -operator, -developer and -imported-at, since the defaults describe the published sessions example and would be false of anyone else's records. Every capsule now says effect_attestation: runtime_claimed, and a granted call or a module run is refused instead of mapped.

The Kubernetes agent-sandbox provider

SANDBOX_PROVIDER=agentsandbox runs snippets and projects on Kubernetes agent-sandbox v1.0.7, a Kubernetes project that keeps sandboxes as pods (Kubernetes' unit of containers run together) with a warm pool started ahead of use. Each run claims one pod, sends its command to sandboxd (the daemon agent-sandbox runs in each pod), and deletes the claim in the foreground, so the pod is gone before the run gives back its capacity. Sessions, grants and module runs are refused before anything is claimed. The operator guide, docs/agentsandbox.md, has every setting, an example template and pool, and what is left to the node. The provider speaks to the Kubernetes API in plain HTTPS and to sandboxd from .proto files copied under third_party/agentsandbox (Apache-2.0), so the root module gains no dependency.

The tier is read, never configured. agent-sandbox lets a template name a runtime class (the Kubernetes object that names the program a node starts containers with) but neither checks it nor reports which one a sandbox got. plimsoll reads the class's handler: runsc (gVisor, a kernel that runs as an ordinary program) is the kernel tier, runc (an ordinary container) the container tier, and kata-clh, kata-clh-runtime-rs and kata the vm tier (Kata Containers starts each pod in a small virtual machine with its own Linux kernel, here on Cloud Hypervisor; kata is the handler behind AKS Pod Sandboxing). Any other handler is refused, Kata's QEMU handlers included. As for every provider, a reported tier is configuration and provider evidence plus startup behaviour checks, never hardware attestation.

Before any code runs, each run reads its pod back and refuses any difference: no API token, no host namespace, one container, a fixed resolver, sandboxd started with the SHA-256 of plimsoll's token, only the SETUID and SETGID capabilities, a read-only root and, under runc, a seccomp profile; the image by digest; every volume a memory-backed emptyDir with a size limit and nothing mounted beneath it; a closed schema, so an unknown pod or container field is refused, and so is an exec handler in a readiness probe, a liveness probe, a startup probe or a lifecycle hook, since Kubernetes would run it as sandboxd's root user; and every network policy that selects the pod, with egress denied by one and allowed by none. The template and the namespace's network policies that the startup smoke test proved are pinned: a changed template or policy refuses every later run until a restart proves it. Each claimed pod is also held to that template, not only the template object: its image, command, security settings, volumes and mounts must be the ones the smoke test proved, so a pod changed after it was created (by an admission webhook, say) is refused, and its first command reads the pod's mount table, refusing a mount nested beneath a writable directory and, under runc and vm, a writable directory that is not a tmpfs. Under gVisor the memory volumes show as 9p shares, accepted because the pod's own spec must declare them memory-backed. (Tested against a fake Kubernetes API and on kind under all four runtimes; no live webhook was installed.)

Every run checks its own pod's tier before its code. Its first command, run as the guest, reads the first line of the kernel log (gVisor's boot line under kernel, a Linux kernel's under vm), the kernel's boot ID (under vm it must differ from the one the pod's node reports) and the seccomp mode (a filter under runc). A handler is a name each node maps in configuration the API does not show: on a test cluster with a node's runsc remapped to runc after startup, a run was signed at the kernel tier on the host's kernel until this check existed. A node's root could forge these markers, so the check catches a misconfigured node, not a hostile one. The startup smoke test also proves from inside two pods the guest's identity, where it can write, that no connection leaves the pod, and that a project step cannot read the runner's or sandboxd's descriptors.

Kata changes three things, measured: no memory-backed volume keeps its size limit (each is a tmpfs of the VM's kernel), so SANDBOX_DISK_MB fails startup under the vm tier and hardened mode does not require it there; the guest runs with no seccomp filter, since the VM is the boundary; and a warm pod answered a snippet in 160 to 360 ms from claim to result, against 50 to 250 ms under gVisor.

Verified on 2026-10-18. The live suite (the provider's tests and a daemon test over RPC) passed on a kind cluster under gVisor, runc and Kata Containers 4.2.0 with both of Kata's runtimes. It also passed at the vm tier on AKS Pod Sandboxing the same day, before the closed pod schema was added; that schema has not run on AKS yet, so a field AKS adds that kind does not would refuse every pod there, naming the field. The evidence page shows two signed runs under gVisor with their bills of materials, the signed record of a run the per-pod check now refuses, and five refusals.

Docker provider

  • The host's resolver settings no longer reach guest code. Docker writes a container's /etc/resolv.conf from the host's files even under --network none: the host's search domains, nameservers and a comment naming the host file (measured on Docker Engine 29.1.3, 2026-10-14). Every container plimsoll starts, session containers included, now mounts plimsoll's own file over it, and the smoke test refuses to start unless the guest sees that file byte for byte.
  • A session's read-back sorts the container's mounts, since docker lists them in no fixed order and an unsorted comparison could end a session as changed when nothing had.
  • Under gVisor, a Python cell that started a child process no longer ends its session. The killed child stayed a zombie (exited, not yet collected by its parent), which gVisor lists with no threads; the sweep read it as still running and ended the session. A killed process that reads as a zombie or dead is now taken as gone, and the session conformance suite tests it on every provider whose sessions offer Python.
  • A session that ran out of memory says so, naming the limit, instead of answering a bare exit 1.

What a caller is shown when something fails

A caller is now shown only text plimsoll composed. Before, a deadline's text was whatever it interrupted (an interrupted HTTP request names its URL), and a cell whose interpreter could not start, or a session's end, could carry a raw docker or vendor error. Now an internal fault reads internal error <id>, as before; a deadline or a cancellation is a fixed sentence; a part of a refusal its provider wrapped with the new sandbox.Private (so far, the text of an interpreter that failed to start) is an error ID; and a session's end keeps its detail only when the provider marked it public (SessionEndedError.Public). Every hidden text goes to the daemon's log under its ID, cut to 4 KiB.

MCP server

clients/mcp is an MCP server for plimsolld (MCP, the Model Context Protocol, is how an AI assistant such as Claude Code, Codex or Claude Desktop calls an outside tool), a Go module of its own over the checked Go client. It runs on your machine over standard input and output and talks to a plimsolld you can reach.

  • Five tools: run_code (Python or JavaScript, fresh each call unless given a sandbox handle), open_sandbox and close_sandbox (a handle whose calls keep files and interpreter state; never the daemon's session ID), get_record (a call's checked run record) and server_info.
  • Failures say whether code ran: ran is no when nothing was sent and maybe when it may have run, and a maybe is never retried.
  • Safe defaults: an isolation floor of kernel, a bounded lifetime for each sandbox (30 minutes), at most three open sandboxes, and a configuration value left as a placeholder refused at startup.
  • Signed receipts: with PLIMSOLL_SIGNING_KEY_FILE and PLIMSOLL_RECEIPTS_DIR, every checked exchange is signed into files only that process writes, and each result names its receipt's line, which plimsoll-attest verify-entry checks on its own. Not yet on Windows.
  • An optional single-owner HTTPS mode (plimsoll-mcp serve-http): passkey sign-in, an explicit approval of each connection, and handles and records scoped to each approval, behind a TLS reverse proxy; it needs a daemon at kernel or vm and signed receipts. clients/mcp/REMOTE.md is its guide.
  • Install: go build -o plimsoll-mcp ./cmd/plimsoll-mcp in clients/mcp. The npm launcher and the assistant bundles in that directory are not published yet.

Clients

  • Python: an agent workspace. plimsoll_client.workspace.Workspace gives an agent framework a shell, files and search over one session (exec, read, write, edit, glob, grep, and persist and hydrate for the whole tree). Every operation is one project call built from fixed scripts, so nothing an agent writes becomes a step.
  • Python: Claude Managed Agents, self-hosted worker (plimsoll_client.anthropic_worker, extra anthropic). It serves the six tools a Managed Agents session sends to a self-hosted worker from a plimsoll session: the key stays on the worker host, and the sandbox holds no credential and has no network.
  • Python: an OpenAI Agents SDK sandbox provider (plimsoll_client.openai_sandbox, extra openai-agents), creating, resuming and deleting sessions through the workspace. Both adapters are tested with each SDK's own classes and no model call; the SDK's own file helpers have not yet been checked on a real docker session.
  • TypeScript: onRecord hands the application every run record the client checked, while the model still gets only its digest.
  • Go: a session the client refuses at its open is closed on the daemon within its own 10-second budget.
  • Grants: the injected host.* client's error for an answer outside 2xx now carries status and body, so code can branch on e.status.

Signed evidence

  • Check one line on its own: plimsoll-attest verify-entry -pub FILE -line N BUNDLE (attest.VerifyEntry) checks one call of a signed record file, for a session still open whose file is not whole yet. It says what it does not establish: that the rest of the file is whole.
  • Sign your own statements about checked runs: Signer.SignStatement and Verifier.OpenStatement sign and open an in-toto statement (a standard wrapper that names, by digest, what a claim is about) of your own type, refusing the run-record types so a generic signature cannot pass for a checked run.
  • plimsoll-attest run now says whether a failed run was refused before anything ran or may have run.
  • The run-record spec gains test vectors for floating-point results (+0, -0, NaN with and without a payload), written from the spec alone.

Examples

  • An AI-written vendor adapter, judged by the vendor's ledger (examples/adapter, needs docker). A fictional robot-fleet vendor's OpenAPI document generates the grant, the client and the description; the harness runs eight scenarios, among them a lost answer and a 500 after the order was accepted, and a verdict made on the host from the vendor's own ledger. The first adapter re-sends an order when an answer is lost and fails five of eight; the rewrite passes all eight. Three fresh AI agents wrote adapters from a prompt: one passed all eight scenarios, two passed seven. Its page.
  • Signed runs as CycloneDX bills of materials (examples/sbom). CycloneDX is a standard parts-list format; each signed run becomes one BOM with its signed record attached, and -check rebuilds it from the evidence. Its page.
  • A receipt for a pull request (examples/pr-receipt): a workflow template that runs a JavaScript project's tests in plimsoll under gVisor on each pull request and emits a signed receipt as a BOM, with no repository secret.
  • The capsule example maps cells and unanswered session calls, and refuses granted calls and module runs (see Upgrading).

Documentation

Contributors

  • CI gains three jobs: capsule, sbom (offline) and mcp (vet, lint, race tests and govulncheck). A skipped test fails each.
  • The docker jobs pull Docker Hub images through a mirror, since Docker Hub's anonymous pull limit refused both docker jobs on the v0.21.0 commit before any test ran; publish-npm.yml publishes ./dist/<file>, which the v0.21.0 publish got wrong.
  • The docker suite runs every Node program the OpenShell, agent-sandbox and E2B providers send into their sandboxes, and sandbox/guest_scripts_test.go fails when a provider embeds one with no such test.
  • examples/sbom keeps its CycloneDX helpers (JSON encoding, image identity and the offline schemas) in a package of their own, examples/sbom/cdxutil.
  • make agentsandbox-suite runs the live agent-sandbox suite against a cluster (free, needs a cluster, fails rather than skips); make receipt-suite runs the pull-request receipt end to end.