Grants for standard client libraries

The agent's code calls the OpenAI and Anthropic libraries as they ship, and never holds the key

Agent-written code reaches for the client library it knows: the OpenAI or Anthropic SDK (software development kit, the vendor's own package for calling its API). That library wants an API key, and you do not want to hand your key to an agent's code. In plimsoll, a grant (INTERNAL · glossary →), one run's permission to call listed routes of one API, can now carry what the library needs: the run's sandbox (INTERNAL · glossary →) gets a local address, the library is told to use it and is handed a dummy key, and the broker (INTERNAL · glossary →), the part of plimsolld (the plimsoll server, or daemon) outside the sandbox that makes every API call, checks each request and puts the real key on it. Below, both libraries ran unmodified that way, and the API they called wrote down everything it received.

How a library's request leaves the sandbox Inside the docker sandbox, which has no network, the agent's code calls the OpenAI or Anthropic library. The library reads its base URL and a dummy key from the environment and sends to plimsoll's forwarder at 127.0.0.1 port 8471, which passes each connection over a Unix socket to the broker in plimsolld. The broker checks the route against the allow list, copies only the headers the profile passes, sets the real key, and sends the request to the API, which on this page is a stub. docker sandbox no network: only its own loopback agent's code + SDK openai or anthropic, as shipped OPENAI_BASE_URL=…127.0.0.1:8471/v1 OPENAI_API_KEY=a dummy forwarder, 127.0.0.1:8471 plimsoll's, checks nothing Unix socket the broker, in plimsolld 1. the route on the allow list? else 403 2. copy only the headers the profile passes 3. set the real key, held here only the profile's base URL the API on this page: a stub on loopback
The library is unchanged, and the container still has no network. What changed: two environment variables, a listener of plimsoll's inside the sandbox, and the seccomp profile that lets it listen.
No real model API is called on this page, and nothing is spent. The API is a stub: a small program of the example's (stub.go (EXTERNAL · source repo ↗)) listening on the host's loopback, which answers like a small part of the two APIs (a chat or messages call, the same call streamed, a file upload) and writes down every request it receives, headers and timings included. That record is what makes the key, the headers and the stream below checkable from outside the sandbox. It says nothing about how OpenAI's or Anthropic's own servers behave; the last section says what that leaves out.

The headline numbers, each a check computed from the files beside this page (13 of 13 hold):

0 of 5requests that reached the API carried the dummy key the libraries held
5 of 5carried the key the daemon holds, which never entered the sandbox
8 of 8streamed pieces (OpenAI) reached the library before the stub wrote the next
12 of 12streamed events (Anthropic), the same
8 MiBuploaded through the broker; the stub's SHA-256 fingerprint of it is the guest's
0requests reached the API on the route the profile does not allow; the broker answered 403

Run on 2026-10-29, with openai 3.41.0 and anthropic 2.3.0 on Python 3.14.8, on one development machine. Every run's run record (INTERNAL · glossary →), plimsoll's statement of what was sent, what came back and where it ran, is signed into bundle.jsonl.

The 30-second version

1

The operator writes a grant profile (INTERNAL · glossary →), a grant stored on the server under a name: the routes it allows, the real key (named by an environment variable of the daemon's), and two new fields. sdk_env is the environment the library reads; pass_headers names the headers the broker may copy from the library's request.

2

A run that selects the profile gets plimsoll's forwarder listening on 127.0.0.1:8471 inside the sandbox, and those variables in its environment. The container still has no network: 127.0.0.1 is its own loopback, the address a machine uses to reach itself, and the forwarder hands each connection, byte for byte, to the broker's Unix socket, a local connection through a file rather than a network.

3

The broker checks the route, rebuilds the request with only the passed headers and the real key, sends it to the API, and passes the answer back: a streamed one piece by piece, as it arrives.

The two profiles this run used, as the daemon read them from its grants file (grants.json; the base URLs are the stub's two loopback ports):

{
  "openai": {
    "base_url": "http://127.0.0.1:45519",
    "allow": [
      "POST /v1/chat/completions",
      "POST /v1/files"
    ],
    "sdk_env": {
      "OPENAI_API_KEY": "dummy-openai-key-in-the-sandbox",
      "OPENAI_BASE_URL": "{origin}/v1"
    },
    "route_max_request_bytes": {
      "POST /v1/files": 16777216
    },
    "route_content_types": {
      "POST /v1/files": [
        "multipart/form-data"
      ]
    },
    "max_request_bytes": 67108864,
    "allowed_callers": [
      "sdk-grants-example"
    ],
    "token": {
      "type": "static",
      "env": "STUB_OPENAI_KEY"
    }
  }
}
{
  "anthropic": {
    "base_url": "http://127.0.0.1:43323",
    "allow": [
      "POST /v1/messages"
    ],
    "pass_headers": [
      "anthropic-version"
    ],
    "sdk_env": {
      "ANTHROPIC_API_KEY": "dummy-anthropic-key-in-the-sandbox",
      "ANTHROPIC_BASE_URL": "{origin}"
    },
    "allowed_callers": [
      "sdk-grants-example"
    ],
    "token": {
      "type": "static",
      "env": "STUB_ANTHROPIC_KEY"
    }
  }
}

The values in sdk_env are what the guest sees. {origin} stands for the local address. The token names the daemon's environment variable that holds the real key; the key itself is never in the file.

What the guest's environment held

The guest (INTERNAL · glossary →), the code running in the sandbox, printed its whole environment, and the environment of every process it could see. These are the variables the profile's sdk_env set, as each run's guest saw them:

RunVariableValue in the sandbox
The OpenAI libraryOPENAI_API_KEYdummy-openai-key-in-the-sandbox
The OpenAI libraryOPENAI_BASE_URLhttp://127.0.0.1:8471/v1
The Anthropic libraryANTHROPIC_API_KEYdummy-anthropic-key-in-the-sandbox
The Anthropic libraryANTHROPIC_BASE_URLhttp://127.0.0.1:8471

The key is a placeholder: both libraries refuse to start without one, and the broker replaces whatever the library sends. The keys the daemon held appear nowhere in what any run was sent or answered: not in the guest's environment, not in its output. The program searched the stored requests and answers in the signed bundle for each key, byte for byte. Each guest could read the environment of its own process and of no other: in the OpenAI run, pid 1, node --eval (PermissionError errno 13) and pid 13, node /runner.mjs (PermissionError errno 13) refused it (errno 13 is permission denied). Those two are plimsoll's own: the forwarder and the project runner. The Anthropic run's guest saw the same 2 refusals.

The OpenAI run's whole environment (15 variables)
HOME/
HOSTNAME41b808e93688
HOST_API_LISTEN127.0.0.1:8471
HOST_API_SOCKET/run/host-api.sock
NODE_OPTIONS--import=file:///tmp/plimsoll-host.mjs
NODE_VERSION22.23.4
OMP_NUM_THREADS1
OPENAI_API_KEYdummy-openai-key-in-the-sandbox
OPENAI_BASE_URLhttp://127.0.0.1:8471/v1
OPENBLAS_NUM_THREADS1
PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PLIMSOLL_RUNNER_PID13
PWD/work
SHLVL1
YARN_VERSION1.22.22

What the library sent, and what the API received

For one call of each library: every header as the library sent it to the local address (read back through the library's own with_raw_response, which adds its x-stainless-raw-response header), beside the same header as the stub received it from the broker.

passed: the profile lists itset by the brokerdropped by the brokera finding

OpenAI: client.chat.completions.create(...)

Sent to http://127.0.0.1:8471/v1/chat/completions; the stub received POST /v1/chat/completions and answered 200: “Hello from the stub API.”

HeaderThe library sentThe API receivedWhat happened
Host127.0.0.1:8471127.0.0.1:45519set by the broker, in place of the library's
Accept-Encodinggzip, deflategzipset by the broker, in place of the library's
Connectionkeep-alivenonedropped by the broker
authorizationBearer dummy-openai-key-in-the-sandboxBearer stub-openai-key-held-by-the-daemon-bc8e8a1ea6ba1cda1c7a4124set by the broker, in place of the library's
acceptapplication/jsonapplication/jsonset by the broker (the same value)
content-typeapplication/jsonapplication/jsonset by the broker (the same value)
user-agentOpenAI/Python 3.41.0Go-http-client/1.1set by the broker, in place of the library's
x-stainless-langpythonnonedropped by the broker
x-stainless-package-version3.41.0nonedropped by the broker
x-stainless-osLinuxnonedropped by the broker
x-stainless-archx64nonedropped by the broker
x-stainless-runtimeCPythonnonedropped by the broker
x-stainless-runtime-version3.14.8nonedropped by the broker
x-stainless-asyncfalsenonedropped by the broker
openai-organizationorg-chosen-by-the-guestnonedropped by the broker
x-stainless-raw-responsetruenonedropped by the broker
x-stainless-retry-count0nonedropped by the broker
x-stainless-read-timeout600nonedropped by the broker
Content-Length7474set by the broker (the same value)

Anthropic: client.messages.create(...)

Sent to http://127.0.0.1:8471/v1/messages; the stub received POST /v1/messages and answered 200: “Hello from the stub API.”

HeaderThe library sentThe API receivedWhat happened
Host127.0.0.1:8471127.0.0.1:43323set by the broker, in place of the library's
Accept-Encodinggzip, deflategzipset by the broker, in place of the library's
Connectionkeep-alivenonedropped by the broker
x-stainless-timeout600nonedropped by the broker
x-stainless-retry-count0nonedropped by the broker
x-stainless-read-timeout600nonedropped by the broker
Acceptapplication/jsonapplication/jsonset by the broker (the same value)
Content-Typeapplication/jsonapplication/jsonset by the broker (the same value)
User-AgentAnthropic/Python 2.3.0Go-http-client/1.1set by the broker, in place of the library's
X-Stainless-Langpythonnonedropped by the broker
X-Stainless-Package-Version2.3.0nonedropped by the broker
X-Stainless-OSLinuxnonedropped by the broker
X-Stainless-Archx64nonedropped by the broker
X-Stainless-RuntimeCPythonnonedropped by the broker
X-Stainless-Runtime-Version3.14.8nonedropped by the broker
AuthorizationBearer dummy-anthropic-key-in-the-sandboxBearer stub-anthropic-key-held-by-the-daemon-29dc33e2fa88addcb933ca5fset by the broker, in place of the library's
X-Stainless-Asyncfalsenonedropped by the broker
anthropic-version2026-10-212026-10-21passed: the profile lists it
anthropic-betaplimsoll-example-not-passednonedropped by the broker
X-Stainless-Raw-Responserawnonedropped by the broker
Content-Length9090set by the broker (the same value)

In the two tables: the library's key (Authorization: Bearer dummy-…) never left the broker: the API got the daemon's key in its place. The library's User-Agent gave way to the broker's own, and the x-stainless-* headers both libraries send (their telemetry: language, version, operating system, retry count) were dropped. So were the two headers the guest added on purpose: OpenAI-Organization, which would let the guest choose which organization a call is billed to, and anthropic-beta, the header that switches on a beta feature, server-side tools among them. The transport headers (Accept, Content-Type, Host and the like) are the broker's, set on every request it makes. Of the headers the library chose, only anthropic-version, which the Claude API requires and the profile lists in pass_headers, was copied through. The broker sends every key as Authorization: Bearer, which is how the Claude API documents sending an API key (authentication (EXTERNAL · official docs ↗), read 2026-10-29; x-api-key is its older header).

The stream, piece by piece

A streamed answer comes back as server-sent events (EXTERNAL · official docs ↗): one HTTP response whose body is a series of small events, written as the model produces them. The stub waited 400 ms between pieces. Had anything on the way held the answer until it ended (the broker, the forwarder or the library), every piece would have reached the library after the stub wrote the last one. Instead, each piece reached the library before the stub had written the next. The times are from one clock: the stub and the sandbox run on the same Linux kernel, and a container reads the host's wall clock.

OpenAI: stream=True, 8 chunks

The OpenAI stream: when the stub wrote each piece and when the library handed it overstub wrotelibrary got0 ms0.5 s1 s1.5 s2 s2.5 s3 s3.5 spiece 1 written at 0 mspiece 1 handed over at 2 mspiece 2 written at 400 mspiece 2 handed over at 401 mspiece 3 written at 801 mspiece 3 handed over at 802 mspiece 4 written at 1202 mspiece 4 handed over at 1202 mspiece 5 written at 1602 mspiece 5 handed over at 1603 mspiece 6 written at 2003 mspiece 6 handed over at 2004 mspiece 7 written at 2405 mspiece 7 handed over at 2406 mspiece 8 written at 2806 mspiece 8 handed over at 2806 mswrite 9, [DONE], at 3218 ms: no piece for the library to hand over

Each dark mark is the stub writing a piece; the teal mark below it is the library handing that piece to the guest's loop. The longest wait from a write to its arrival: 2 ms. First to last arrival: 2804 ms. The hollow mark is the stub's last write, data: [DONE], which ends an OpenAI stream: the library reads it and hands no piece over. Each piece's text is the one the stub wrote (8 of 8).

Every piece, in milliseconds from the stub's first write
PieceTextStub wroteLibrary gotStub wrote the next
1Pieces 02400
2of 400401801
3an 8018021202
4answer, 120212021602
5one 160216032003
6at 200320042405
7a 240524062806
8time.280628063218

Anthropic: stream=True, 13 events

The Anthropic stream: when the stub wrote each event and when the library handed it overstub wrotelibrary got0 ms1 s2 s3 s4 s5 spiece 1 written at 0 mspiece 1 handed over at 4 mspiece 2 written at 401 mspiece 2 handed over at 402 mspiece 3 written at 802 mspiece 3 handed over at 803 mspiece 4 written at 1204 mspiece 4 handed over at 1204 mspiece 5 written at 1604 mspiece 5 handed over at 1605 mspiece 6 written at 2005 mspiece 6 handed over at 2005 mspiece 7 written at 2406 mspiece 7 handed over at 2407 mspiece 8 written at 2807 mspiece 8 handed over at 2807 mspiece 9 written at 3208 mspiece 9 handed over at 3209 mspiece 10 written at 3609 mspiece 10 handed over at 3609 mspiece 11 written at 4009 mspiece 11 handed over at 4010 mspiece 12 written at 4410 mspiece 12 handed over at 4411 mspiece 13 written at 4811 mspiece 13 handed over at 4812 ms

The Claude stream sends named events: a start, the text deltas, a stop. The longest wait from a write to its arrival: 4 ms. First to last arrival: 4807 ms. Each event's type and text are the ones the stub wrote (13 of 13).

Every event, in milliseconds from the stub's first write
EventNameStub wroteLibrary gotStub wrote the next
1message_start04401
2content_block_start401402802
3content_block_delta: Pieces 8028031204
4content_block_delta: of 120412041604
5content_block_delta: an 160416052005
6content_block_delta: answer, 200520052406
7content_block_delta: one 240624072807
8content_block_delta: at 280728073208
9content_block_delta: a 320832093609
10content_block_delta: time.360936094009
11content_block_stop400940104410
12message_delta441044114811
13message_stop48114812

The upload

The guest uploaded 8 MiB with client.files.create(file=("sample.bin", data, "application/octet-stream"), purpose="user_data"). The library sends that as multipart/form-data (EXTERNAL · official docs ↗), one request body in named parts. A route takes a body over 1 MiB only when its profile raises the route's limit and names the type: here POST /v1/files is raised to 16 MiB and takes multipart/form-data, and the run may send 64 MiB in all.

PartFile nameTypeBytesSHA-256 at the stub
purpose93279da18bfd0f199ae711d946059b48b901df03b584d4c51f14db38b15144137
filesample.binapplication/octet-stream8388608fa16e68dc6e4ff55d4eaf64f51a79ea3d3444b4b128c6856137e74b008605a06

The guest computed SHA-256 fa16e68dc6e4ff55d4eaf64f51a79ea3d3444b4b128c6856137e74b008605a06 over the bytes it sent; the stub computed fa16e68dc6e4ff55d4eaf64f51a79ea3d3444b4b128c6856137e74b008605a06 over the file part it received, the same. The whole request body the stub received was 8388891 bytes, and the library got back the stub's file object, file-stub-1.

The refusal

The guest called client.embeddings.create(model="stub-embedding", input="hello"), which is POST /v1/embeddings. The OpenAI profile allows POST /v1/chat/completions and POST /v1/files and nothing else. The library raised PermissionDeniedError, status 403, with the broker's own answer:

forbidden by sandbox capability allowlist

Three things show that the broker refused it and the API never saw it. The stub serves /v1/embeddings and would have answered 200, yet it received 0 requests there. The answer carries X-Should-Retry: false, which every answer the broker makes itself carries, and the library honors it: the attempt that raised was its first (x-stainless-retry-count: 0), so the refusal spent no further calls. And the daemon's audit line for the run counts 1 call denied by policy.

The call trace, as the daemon reports it

The broker keeps a metadata-only list of each run's calls: the route pattern each matched, its status, its sizes and its time, never a path, a body or a key. The daemon does not send that list back with the answer; it reports it as counts on the run's audit line (the line the daemon logs for every run) and per route in /metrics (the counters it serves to monitoring), both read after the runs:

RunProfileCalls brokeredDeniedPer route, from /metricsRequests the stub got
The OpenAI libraryopenai31POST /v1/chat/completions: 2 in 3.219 s
POST /v1/files: 1 in 0.023 s
3
The Anthropic libraryanthropic20POST /v1/messages: 2 in 4.812 s
2
The control run: no grantnone00none0

The latency is the broker's round trip to the stub and back for the calls of that route, summed; for the streamed calls it includes the stub's 400 ms waits.

The run records, and the second seccomp profile

Each record states where its run ran: the isolation tier (INTERNAL · glossary →), how strong the wall around it was; the image, the packaged file system the sandbox started from, by its content identity; and the policy. The policy field names the seccomp (INTERNAL · glossary →) profile docker applied to the run, the kernel's list of the operations a program may ask it for, by the SHA-256 of its bytes. The shipped default allows Unix sockets only, so under it no forwarder could listen. A run whose grant sets sdk_env gets a second profile, which the daemon derives from the default at startup (SANDBOX_DOCKER_SECCOMP_SDK=derived) and pins by its digest, so the two records below name different profiles:

RunTierImagePolicyProfileRecord
The OpenAI librarycontainerbf5af9606ed3c5b32b1a17c3the sdk_env variantfda8ba7ada2c
The Anthropic librarycontainerbf5af9606ed3c5b32b1a17c3the sdk_env variantf4f25aa46924
The control run: no grantcontainerbf5af9606ed3a39bc7bf6cf7the default372262671761

Both profiles are published beside this page: seccomp-default.json (8.2 KiB, the repository's docker/seccomp.json, SHA-256 a39bc7bf6cf7…) and seccomp-sdk.json (13.0 KiB, the bytes the daemon pinned, c5b32b1a17c3…). The daemon's own description of the pair, as it stated it: seccomp:sha256:a39bc7bf6cf712f4d65d108f615aeae5785d8def8bc0e658fbd4e36a6305b089 sdk_env:seccomp:sha256:c5b32b1a17c39da0438800a99bd0607700877ac8d01b72b839394023f0d64376. Compared one syscall rule at a time, the variant keeps 243 of the default's rules as they are, takes out 2 and adds 10:

SyscallWhen the arguments areThe rule's own comment
removedgetsockoptany
removedsetsockoptany
addedgetsockoptarg1 == 1, arg2 == 3getsockopt allowed for one option libuv reads (docs/seccomp.md).
addedgetsockoptarg1 == 1, arg2 == 4getsockopt allowed for one option libuv reads (docs/seccomp.md).
addedsetsockoptarg1 == 1, arg2 == 2setsockopt allowed for one option the forwarder or a client transport sets (docs/seccomp.md).
addedsetsockoptarg1 == 1, arg2 == 9setsockopt allowed for one option the forwarder or a client transport sets (docs/seccomp.md).
addedsetsockoptarg1 == 6, arg2 == 1setsockopt allowed for one option the forwarder or a client transport sets (docs/seccomp.md).
addedsetsockoptarg1 == 6, arg2 == 4setsockopt allowed for one option the forwarder or a client transport sets (docs/seccomp.md).
addedsetsockoptarg1 == 6, arg2 == 5setsockopt allowed for one option the forwarder or a client transport sets (docs/seccomp.md).
addedsetsockoptarg1 == 6, arg2 == 6setsockopt allowed for one option the forwarder or a client transport sets (docs/seccomp.md).
addedsocketarg0 == 2, arg1 & 0xfff7f7ff == 1, arg2 == 0AF_INET (2) stream sockets, protocol 0: the type is masked so only SOCK_NONBLOCK and SOCK_CLOEXEC may join SOCK_STREAM; UDP, raw, SCTP (132) and MPTCP (262) fail the type or protocol check.
addedsocketarg0 == 2, arg1 & 0xfff7f7ff == 1, arg2 == 6AF_INET (2) stream sockets, IPPROTO_TCP (6).

In words: socket may now make an IPv4 (AF_INET, 2) TCP stream socket, and nothing else new; setsockopt and getsockopt, which the default allows with any arguments, are allowed only for the few options the forwarder and the libraries set. What each run's guest tried, and got:

RunIPv4 TCP socketUDP socketIPv6 socketConnect to 192.0.2.1:443
The OpenAI libraryopensrefused: PermissionError: errno 1, Operation not permittedrefused: PermissionError: errno 1, Operation not permittedrefused: OSError: errno 101, Network unreachable
The Anthropic libraryopensrefused: PermissionError: errno 1, Operation not permittedrefused: PermissionError: errno 1, Operation not permittedrefused: OSError: errno 101, Network unreachable
The control run: no grantrefused: PermissionError: errno 1, Operation not permittedrefused: PermissionError: errno 1, Operation not permittedrefused: PermissionError: errno 1, Operation not permittedrefused: PermissionError: errno 1, Operation not permitted

192.0.2.1 is set aside for documentation (RFC 5737 (EXTERNAL · official docs ↗)): the address only shows whether the sandbox has a route out, and under --network none it has none. The added sockets reach only the container's loopback, but they are kernel code the default withholds, which is why the variant is a profile of its own and applies only to runs whose grant sets sdk_env; docs/seccomp.md (EXTERNAL · official docs ↗) says what it adds in full.

What this proves, and what it doesn't

Proves
  • Every run's record is signed by the harness and checks against the request and answer stored with it. (3 single runs verified, 0 sessions)
  • No request the API received carried the guest's dummy key, in a header or in a body. (0 occurrences in 5 requests)
  • Every request the API received carried the key the daemon holds for that API. (5 of 5 requests)
  • The keys the daemon holds appear nowhere in what each run was sent or answered: not in its environment, not in any process environment the guest could read, not in its output. (0 occurrences; 3 process environments read)
  • Of the guest's headers, only the one the profile passes reached the API; the broker set its own credential and transport headers. (OpenAI-Organization dropped, anthropic-beta dropped, anthropic-version passed; 0 headers leaked, altered or lost)
  • Each piece of both streamed answers reached the guest's library before the stub wrote the next one. (openai: 8 of 8 pieces before the next was written, 8 of 8 matching the stub's, first to last 2804 ms; anthropic: 12 of 12 pieces before the next was written, 13 of 13 matching the stub's, first to last 4807 ms)
  • The 8 MiB upload reached the API byte for byte, on a route raised above the 1 MiB default. (guest fa16e68dc6e4ff55, stub fa16e68dc6e4ff55)
  • The call to a route the profile does not allow was refused by the broker, marked not to retry, made once, and never reached the API. (status 403, X-Should-Retry "false", retry count "0", 0 requests at the route, 1 denied on the audit line)
  • The two runs whose grant sets sdk_env ran under the derived variant, the control run under the default, each named by digest in its record. (openai variant, anthropic variant, plain default)
  • Under the variant an IPv4 TCP socket opens and a UDP or IPv6 socket does not, and an outside address is unreachable; under the default no IPv4 socket opens. (openai IPv4 TCP allowed, UDP errno 1, IPv6 errno 1, outside address errno 101; anthropic IPv4 TCP allowed, UDP errno 1, IPv6 errno 1, outside address errno 101; control IPv4 TCP errno 1, UDP errno 1, IPv6 errno 1, outside address errno 1)
  • The guest imported the pinned versions of both libraries. (openai 3.41.0 (pinned 3.41.0), anthropic 2.3.0 (pinned 2.3.0))
  • The stub received only calls on routes the profiles allow: three from the OpenAI run, two from the Anthropic run, none from the control run. (0 off the allow lists; 3, 2 and 0)
  • The daemon's audit line for each run counts the calls the stub received, and the one refusal. (openai 3 calls 1 denied; anthropic 2 and 0; plain 0)
Doesn't prove
  • How OpenAI's or Anthropic's servers answer. The stub stands in for them, shaped like the requests the two libraries send and the answers they parse; neither vendor's API was called.
  • E2B and Docker Cloud. On those providers the sandbox is a virtual machine that reaches the broker through the guard (INTERNAL · glossary →), an HTTPS address outside it, and the guard's raw route takes a standard client's request; the documentation describes the live tests (TestE2BGuardLive, TestDockerCloudGuardLive) that send one through it with Node's fetch. sdk_env itself is not built on those providers, and none of that ran for this page.
  • That a chat API's own tools are safe to grant. The documentation's warning, quoted: “The broker checks the route and never reads the body, so everything the body can ask the API to do is granted along with the route.” A guest allowed POST /v1/messages can ask the Claude API to fetch a web page, and one allowed the Responses API can name a remote MCP (INTERNAL · glossary →) server, a program serving tools to a model; turning those tools off is done on the API's side (the warning and what to do about it (EXTERNAL · official docs ↗)).
  • Sessions, snippets, or another language's library. These were three project runs of Python code. A grant with sdk_env in a session (INTERNAL · glossary →) is refused before anything runs.
  • That the stub is honest. Its log is its own statement. The harness (INTERNAL · glossary →), the program outside the daemon that signs records, signed the log's digest beside the runs' records, which shows the log was not changed afterwards, not that it is true. The guest's side (the headers the library sent, when each piece arrived) is in each run's own output, which its record covers.
  • That the hardware is honest. The isolation tier in a record is the server's configuration, the provider's evidence and its startup checks, not a hardware attestation.

Run it yourself

From a clone of the repository (EXTERNAL · source repo ↗), with docker:

make docker-images                    # plimsoll's sandbox images, plimsoll/sandbox-python among them
go run ./examples/sdk-grants          # builds the example's image and plimsolld, runs everything, rewrites this page
go run ./examples/sdk-grants -verify  # rechecks the files beside this page; starts nothing
go run ./cmd/plimsoll-attest verify -pub docs/examples/sdk-grants/harness.pub docs/examples/sdk-grants/bundle.jsonl

The example's image is plimsoll's Python image with the two libraries installed from wheels (Python's package files) pinned by version and SHA-256 (requirements.txt (EXTERNAL · source repo ↗), 17 packages): openai 3.41.0 (EXTERNAL · package index ↗, wheel 7060a74b2396…) and anthropic 2.3.0 (EXTERNAL · package index ↗, wheel aa6a3984f927…). Building it fetches those wheels; the runs have no network. Image plimsoll-wip/sdk-grants:dev, bf5af9606ed3, built on plimsoll/sandbox-python:latest, 48666c14b387. The program spends nothing: no model, no paid sandbox. Everything on this page comes from evidence.json, which -verify rebuilds from these files:

observations.dsse.json is the harness's signed statement (a DSSE (INTERNAL · glossary →) envelope, the signing format the run records use) of what it saw outside the runs (the daemon's description of itself, its audit lines and /metrics, the image, the keys it handed the daemon, and the SHA-256 of the stub's log, the grants file and both profiles), naming the three run records. The keys are random values made for this run; they open nothing but the stub, which no longer runs. Harness key ID 9264f8cf24ee225014c4359279b8b3513ffca1e771b2f4719b148404ce21d7c7.