← all docs

Security

Last modified September 10, 2026

Benchmark runs execute LLM-generated code: setup scripts, dependency installs, the benchmark's task_impl.py, and whatever shell commands the agent decides it needs. That code is untrusted in the same way any third-party code is untrusted. This page describes the mechanisms that keep the secrets you've stored in your vault out of that code's reach, and is honest about the places where the boundary has known gaps.

1. Threat model

We defend against:

We do not defend against:

2. The boundary: sandboxes per run

A run spawns up to four kinds of isolated sandbox, or five through the Harbor bridge, depending on the benchmark and launch mode. A run that fans out has one task sandbox per task, so a wide run has many boxes rather than four. Each is a separate container with its own filesystem, process namespace, and network path. They cannot read each other's memory, environment, or files.

How Benchwright isolates your secrets: the work and task sandboxes run your benchmark and hold no vault secrets; the proxy sandbox holds them and is the only box that can reach your credentials; the driver sandbox runs the pipeline.

Task sandboxes appear only when a benchmark fans out: one per task, created for that task and destroyed when it finishes. They follow the same rules as the work sandbox and never hold vault credentials.

Runs that go through the Harbor bridge add one more box, an orchestrator sandbox, which runs the Harbor CLI and spawns Harbor's own per-task environments. It holds no vault credentials either.

The work sandbox is the agent's playground. The proxy sandbox is the credential boundary. The agent cannot get a shell in the proxy sandbox; it cannot list its files; it cannot read its environment variables. It can only send HTTP requests to the proxy port, and the proxy decides what to do with them.

The exact isolation primitive depends on the compute backend a run lands on, and it is worth being precise about it. The default backend for every account is Benchwright Cloud, our own sandbox0 cluster running on DigitalOcean Kubernetes. There, each sandbox is a separate Kubernetes pod, and pods scheduled onto the same node share that node's kernel. The fallback backend is E2B, where each sandbox is a separate microVM with its own kernel. Per-run credential isolation holds on either backend: your vault secrets stay in a separate proxy sandbox that the agent cannot directly access.

All sandboxes for a run are torn down when the run finishes, with one exception. A run can ask for the proxy sandbox to linger past the end of the run (up to 24 hours) so its model-proxy dashboard stays inspectable. During that window the proxy sandbox still holds the run's credentials; it idle-pauses when nothing is talking to it, is resumed on demand, and is reaped when the window lapses. Outside of the run's own sandboxes, your secrets are never persisted on disk.

3. How the proxy enforces your vault

Your vault has three pieces:

When a run starts, Benchwright fetches your vault over TLS and writes it to a config file inside the proxy sandbox. What protects that file is the sandbox boundary, not its permission bits: it sits on a different box from the agent, and the agent has no shell, no login, and no filesystem access there.

For each request the proxy receives:

  1. Look up the destination host in your host_policy. If it's block, return HTTP 403 to the agent and log the attempt.
  2. Otherwise, walk your rules. The first rule whose host_pattern matches the destination wins; the proxy rewrites the request's Authorization header (or query parameter) to inject the matching secret value.
  3. Forward the request to the real host using your real credential. The agent never saw the real value — only the request shape.

The proxy uses its own per-run TLS certificate authority to terminate TLS for the intercepted hosts. The work sandbox trusts that CA at run setup. The CA's private key lives only in the proxy sandbox and is destroyed with it.

4. Scope of interception

The proxy intercepts only the outbound HTTPS traffic that your vault configuration designates for enforcement. A blanket interception model, in which every request from the work sandbox is routed through the proxy, was rejected for two reasons. First, it would impose proxy and TLS-termination overhead on requests for which the vault expresses no policy. Second, it would expand the proxy's TLS-interception surface to every host the agent contacts, including routine package and source repositories such as pypi.org, github.com, and apt mirrors. Any TLS-trust regression on those paths silently breaks dependency installation, which creates incentive for the agent to disable proxy settings and circumvent enforcement entirely.

Accordingly, in the default routing mode the proxy intercepts traffic to a destination host if, and only if, one of the following conditions is met:

Traffic directed at any host that does not satisfy one of the above conditions is forwarded from the work sandbox to its destination without proxy involvement or TLS interception. To bring a host within the scope of enforcement, whether for the purpose of credential injection or denial, the host must be configured in your vault. Hosts not so configured are not intercepted, and Benchwright does not inspect or modify the corresponding traffic.

Because redirection works by pointing the work sandbox's /etc/hosts at the proxy, and /etc/hosts cannot express a wildcard, the default routing mode can only redirect exact hostnames.

Catch-all routing. There is a second routing mode in which every outbound request on ports 80 and 443 is redirected into the proxy and the proxy is the only way out of the sandbox. In that mode every host reaches the proxy, so wildcard patterns match and a default-deny policy is a real allowlist.

Your vault selects the routing mode; you do not choose it directly. Three settings cannot mean what they say under the default mode, so configuring any of them switches that run to catch-all routing:

Everything else keeps the default mode. A vault with an allow default and exact-match entries routes exactly as described above. Runs that egress through your own machine also use catch-all, for the same reason: your machine cannot police traffic that never reaches it.

Catch-all is not free, which is why it is scoped to the vaults that need it rather than being turned on for everyone. Every connection goes through the proxy sandbox instead of only the matched ones, so the proxy is on the critical path for all of them, and TLS is terminated for hosts the vault expresses no opinion about, including package and source repositories. If you set a block default, allow the hosts your benchmarks need before your next run, package hosts included; the starter pack on your vault page covers the common ones.

One consequence is worth stating plainly because it is a deliberate trade against availability. Under a block default the redirect is what makes the deny real, so if it cannot be installed in a sandbox the run stops with an error rather than proceeding with the deny inactive. A run that could not enforce your allowlist is not a run that happened to be slower; it is a run outside the boundary you asked for, and we would rather fail it than hand you a score from it.

5. What lives where

Material Lives in Lifetime
Your decrypted vault values Proxy sandbox, config file + proxy process memory Per-run
Proxy CA private key Proxy sandbox Per-run
Proxy CA public cert Trusted in the work sandbox Per-run
Your model-provider key for the model Proxy sandbox env (the model proxy reads it) Per-run
Benchmark data, agent's task code, run results Work sandbox Per-run
Benchwright's own LLM key for the pipeline driver Driver sandbox Per-run
Benchwright's compute-control token for the run Work sandbox (see gaps below) Per-run

None of these outlive the run's sandboxes, and secrets are pulled fresh from your vault on every run start. Where a run has asked for a proxy linger window, the three proxy-sandbox rows above live until that window lapses rather than until the run ends.

6. Auditing what got blocked

Every time the proxy refuses a request, it records it in blocked_host_log: your user, the run id, the host, how many times that host was attempted (seen_count), when it was first and last seen, and which enforcement point refused it (source, which is the run's proxy, your local-egress agent, or both). You see these in two places. Account-wide, under "Recent blocked hosts" on your vault page. Per run, in the denied-hosts card on the run dashboard.

Use this view to:

There is a second log with the same guarantee. egress_route_log records, per run, which hosts were reached and by which route (through your tunnel, direct, or refused), and is readable at GET /v1/runs/{id}/egress-routes. It is a routing record rather than a traffic record: host and route only.

Both logs record hostnames, routes, and counts. Path, query string, request body, and response body are never recorded — there is no way for the proxy or this UI to leak the content of an intercepted request.

There is a third thing worth checking, and it is the absence of enforcement rather than a record of it. A run that went out without the proxy in the path says so under "Network the platform changed" on its dashboard, with the reason. A vault holding no rules, no block entries and an allow default has nothing for the proxy to do, and that reads as a plain note; a run whose proxy could not be set up reads as a warning, because its traffic left unfiltered. Both used to look the same from the outside, which is to say they both looked like nothing.

7. Known gaps and trade-offs

We're being explicit about these so you can decide whether the boundary fits your threat model.

If you have questions about the boundary or want to talk through your specific threat model, email legal@benchwright.ai with the subject line "Security" and we'll get back to you. To file a defect against a specific run, POST /v1/bug-reports with that run's id attaches the report to the run's trace.