kosa8 docs

Guides / Keep API keys out of the sandbox

Keep API keys out of the sandbox

An AI agent needs an API key to do anything. It does not need to hold one. With a brokered kit the key stays on your computer; the sandbox is given a placeholder that only works from inside that sandbox, and the real key is added to each request after it has left.

What changes

A kit is the recipe kosa8 agent run builds an agent's sandbox from: an image, a command, the hosts it may reach, and its credentials. Kits used to copy your key into the sandbox. Brokered kits do not.

Key passed in

The real key is an environment variable inside the sandbox.

  • The agent can read it. So can every package it installs and every script it runs.
  • It can be sent to any host the sandbox is allowed to reach.
  • Once copied out, it works from anywhere until you revoke it.

Key brokered

The sandbox holds a random placeholder and the address of a broker.

  • Reading the environment yields nothing that works outside this sandbox.
  • The real key is only ever sent to the one upstream it belongs to.
  • When the sandbox stops, the placeholder stops meaning anything.

The egress broker is the part that makes this work. It is a small HTTP proxy that runs inside the kosa8d service on your computer, one per sandbox. "Egress" means traffic leaving the sandbox. The broker is the only way out a brokered sandbox has.

One request, start to finish

A brokered request The agent's command-line tool, inside the sandbox, sends a request carrying a placeholder instead of a key. It leaves through a single open port, the pinhole, and arrives at the egress broker on your computer. The broker checks the method, the path and the placeholder, replaces the placeholder with the real key held in memory, and forwards the request over verified TLS to the one upstream the key belongs to. The decision is written to the audit log without the key. Sandbox — untrusted Your computer — trusted agent CLIholds a placeholder,never your key POST /anthropic/v1/… x-api-key: placeholder pinhole: one open port egress brokerchecks the method, the pathand the placeholder swaps in the real keyheld in the service's memory only upstreamapi.anthropic.com,over verified TLS audit logmethod, path, status; never the key
The key and the agent are never in the same place. The swap happens after the request has left the sandbox.
  1. The agent's tool reads its key and its API address from the environment, as it always does. Both are stand-ins.
  2. It sends the request to that address, with the placeholder where the key would go. One port is open for this; nothing else is.
  3. The broker checks the method and path against the route, and requires the right placeholder.
  4. It writes the real key into the header, in place of the placeholder.
  5. The request goes to the one upstream that key belongs to. Redirects are not followed, so the upstream cannot bounce the key elsewhere. The answer streams back unbuffered.
  6. Allowed or refused, the request is recorded: host, port, method, path and status.

What a stolen placeholder is worth

Assume the worst: something in the sandbox reads the placeholder and sends it to an attacker. This is what they can do with it.

A stolen placeholder used from elsewhere A placeholder copied out of a sandbox is tried in three places. Sent straight to the API, it is not a key and means nothing. Sent to another sandbox's broker, it does not match that sandbox's own random placeholder and is refused with 403. After the sandbox it came from stops, the broker and the key it held are gone. stolen placeholder kosa8-placeholder-1803c8…copied out of the sandbox sent straight to the APIit is not a key; the provider has never issued it sent to another sandbox's brokereach sandbox has its own random placeholder: 403 used after the sandbox stopsthe broker, and the key it held, are gone
Away from its own sandbox and its own broker, a placeholder is a random string.

The one place it does work is where it was issued: inside that sandbox, through that broker, while it runs. That is still a real power, and it is the first item under limits.

Try it

Check what a kit will do before it runs

Have your key in your shell's environment the way the tool normally expects it, for example ANTHROPIC_API_KEY. Then ask for a dry run, which prints the plan and starts nothing.

$ kosa8 agent run --dry-run claude
kosa8: kit claude (node:22-alpine)
kosa8: workspace /Users/you/code/api -> /work (nothing else on the host is visible)
kosa8: network closed except through the egress broker: registry.npmjs.org, statsig.anthropic.com
kosa8: brokering ANTHROPIC_API_KEY -> api.anthropic.com (the sandbox holds a placeholder, never the key)
kosa8: --dry-run, stopping before creating the sandbox

Run it

Flags go before the kit name. -w names the one folder the agent may see; it defaults to the current directory.

$ kosa8 agent run -w ~/code/api claude
kosa8: kit claude (node:22-alpine)
kosa8: workspace /Users/you/code/api -> /work (nothing else on the host is visible)
kosa8: network closed except through the egress broker: registry.npmjs.org, statsig.anthropic.com
kosa8: brokering ANTHROPIC_API_KEY -> api.anthropic.com (the sandbox holds a placeholder, never the key)
kosa8: sandbox claude-44059 ready in 653ms
kosa8: pulling node:22-alpine
kosa8: running kit setup, then: claude
...
kosa8: sandbox claude-44059 removed

The fourth line is the one to look for. If instead you see a line that begins kosa8: passing, that kit is putting a real key in the sandbox.

If the key is not set

kosa8 agent run reads the key from its own environment. If the variable is missing it says so, and that credential's route is left out, so the agent fails to authenticate instead of reaching the API with nothing:

kosa8: warning: ANTHROPIC_API_KEY is not set on the host; the agent may fail to authenticate

The sandbox is removed when the agent exits, and the broker with it. --keep keeps the sandbox; see where the key lives for why a kept sandbox does not keep its broker across a restart. All the flags are on the agent run reference page.

Which kits are brokered

kosa8 agent kits lists the kits you can run. The KEYS column says where each one's credentials will be.

$ kosa8 agent kits
KIT       IMAGE           NETWORK                             KEYS                  DESCRIPTION
azmx      node:22-alpine  api.azmx.ai,registry.npmjs.org      brokered + passed in  UNAVAILABLE — AZMX AI agent CLI in an isolated sandbox
claude    node:22-alpine  registry.npmjs.org,statsig.anthr..  brokered              Claude Code in an isolated sandbox
codex     node:22-alpine  registry.npmjs.org                  brokered              OpenAI Codex CLI in an isolated sandbox
copilot   node:22-alpine  api.githubcopilot.com,api.github..  passed in             GitHub Copilot CLI in an isolated sandbox
cursor    node:22-alpine  api2.cursor.sh,api.cursor.sh        passed in             UNAVAILABLE — Cursor CLI (cursor-agent) in an isolated sandbox
gemini    node:22-alpine  registry.npmjs.org                  brokered              Gemini CLI in an isolated sandbox
opencode  node:22-alpine  registry.npmjs.org                  brokered              OpenCode in an isolated sandbox
shell     alpine:latest   none                                none                  A plain shell with the workspace mounted and no network

brokered: the key stays on this machine and the sandbox holds a placeholder
passed in: the real key is in the sandbox's environment
kits in ~/.kosa8/kits override built-ins of the same name
KEYSMeaning
brokeredEvery credential the kit names stays on your computer. The sandbox holds placeholders.
passed inThe real value is copied into the sandbox's environment, where the agent can read it.
brokered + passed inSome of each. The run's opening lines say which is which.
noneThe kit uses no credentials.

The API's host does not appear in the NETWORK column of a brokered kit. That is deliberate: claude lists the package registry and a telemetry host, and reaches api.anthropic.com only through its credential's route. The agent cannot open its own connection to the API, with or without a key.

What each brokered kit does

KitKey read on your computerSent only toIn the headerThe sandbox is given
claudeANTHROPIC_API_KEYapi.anthropic.comx-api-key ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY (placeholder)
codexOPENAI_API_KEYapi.openai.comAuthorization: Bearer OPENAI_BASE_URL, OPENAI_API_KEY (placeholder)
geminiGEMINI_API_KEYgenerativelanguage.googleapis.comx-goog-api-key GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY (placeholder)
opencodeANTHROPIC_API_KEY, OPENAI_API_KEYeach to its own APIas above both base URLs and both placeholders

The three exceptions

copilot still passes its token in. Brokering works by pointing the agent's tool at the broker instead of at the API, so it needs a tool that accepts a different base URL. The Copilot CLI has no such setting. Its kit therefore still copies GITHUB_TOKEN into the sandbox, and every run says so:

kosa8: passing GITHUB_TOKEN into the sandbox as plain environment variables — the agent can read them

Give that kit a token with the narrowest scope that works, and treat it as exposed to whatever the agent runs.

cursor and azmx are unavailable. A kit installs the agent's tool before it hands over a key, so kosa8 will not ship a kit whose installer it cannot verify. The cursor kit used to install an npm package named cursor-agent, which is not published by Cursor. The azmx kit names a package that is not registered at all, so anyone could publish under that name and be handed the keys. Both refuse to run and say why:

$ kosa8 agent run cursor
kosa8: kit cursor cannot be run as shipped: the npm package "cursor-agent" is not published by Cursor, so this kit no longer installs it; write a kit that installs Cursor's CLI from a source you have verified (`kosa8 agent init cursor`)

See Kits for pinned packages and signing.

Broker your own tool's key

Any HTTP API whose client lets you set its base URL can be brokered. You describe the credential in a kit file under credentials:. This is a complete kit: a shell that can call a weather API without holding its key.

# weather.yaml
name: weather
description: A shell that can call a weather API without holding its key
image: alpine:latest
command: [sh]
egress_allow:
  - docs.example.com
credentials:
  - name: weather
    upstream: https://api.example.com
    secret: WEATHER_API_KEY
    header: Authorization
    scheme: "Bearer "
    methods: [GET]
    paths: [/v1/forecast]
    env:
      WEATHER_API_URL: "{{url}}"
      WEATHER_API_KEY: "{{placeholder}}"
$ kosa8 agent run --dry-run ./weather.yaml
kosa8: warning: kit file ./weather.yaml is unsigned: nothing vouches for its contents (sign it with `kosa8 agent sign`, and pin the key with `kosa8 policy trust`)
kosa8: kit weather (alpine:latest)
kosa8: workspace /Users/you/code/api -> /work (nothing else on the host is visible)
kosa8: network closed except through the egress broker: docs.example.com
kosa8: brokering WEATHER_API_KEY -> api.example.com (the sandbox holds a placeholder, never the key)
kosa8: --dry-run, stopping before creating the sandbox

Inside that sandbox, a client that reads WEATHER_API_URL and WEATHER_API_KEY can GET anything under /v1/forecast at api.example.com, and can open docs.example.com. It can do nothing else on the network.

Every field

FieldRequiredWhat it is
nameyesThe route's name: 1 to 32 characters of a-z, 0-9 and -, unique within the kit. It becomes the first path segment of the address the sandbox calls.
upstreamyesThe only place the key is ever sent. It must be https://host with no path, no query and no user@ part. Put a path under paths or after {{url}} instead.
secretyesThe name of the environment variable on your computer that holds the real key. Never the key itself. kosa8 agent run reads it when it starts.
headeryesThe request header the upstream reads its key from, such as x-api-key or Authorization. Headers that proxies remove (Connection, anything starting Proxy-) and Host or Content-Length are refused.
schemenoText written before the key in the header. For a bearer token it is "Bearer ", with the space. The sandbox must send the same scheme before its placeholder.
methodsnoThe HTTP methods the key may be used with. Left out, any method is allowed except TRACE, which is never forwarded. CONNECT and TRACE cannot be listed.
pathsnoPrefixes of the upstream path the key may be used on. Left out, any path is allowed. Each must start with / and have no empty, . or .. segment.
envyesThe environment variables the sandbox is given in place of the real ones. In a value, {{url}} becomes the route's address at the broker and {{placeholder}} becomes the stand-in for the key. At least one variable must contain {{placeholder}}.

Three details decide whether a route means what you intended.

When the kit file is wrong

A kit is checked when it is loaded, and the message names the credential and the fix. These are the messages for three common mistakes:

kosa8: ./weather.yaml: credential "weather": env must give the sandbox {{placeholder}} in some variable
kosa8: ./weather.yaml: credential "weather": upstream "https://api.example.com/v1" must be https://host with no path
kosa8: ./weather.yaml: credential name "Weather_API" must be 1-32 characters of a-z, 0-9 and '-'

Path prefixes and header names are checked a step later, by the broker, when the sandbox is created.

On a governed machine

Where your organisation has pinned a signing key, a kit file must be signed by it or it does not load: kosa8: kit ./weather.yaml is not signed, and this machine only runs kits signed by its org's key; have it signed with `kosa8 agent sign`. See agent sign.

What the sandbox sees

This is the environment of a real brokered sandbox, from a test kit with one credential named echo for httpbin.org:

ECHO_KEY=kosa8-placeholder-1803c857986afe6bc9f59600e4d6f6f8
ECHO_URL=http://192.168.127.254:57504/echo
HTTPS_PROXY=http://192.168.127.254:57504
VariableWhat it holds
the {{placeholder}} variablekosa8-placeholder- and 32 random hexadecimal characters, fresh for each sandbox. Recognisable on purpose: one found in a log is obviously not a real key.
the {{url}} variableThe broker as the sandbox reaches it, then the route's name. 192.168.127.254 is an address on the sandbox's own private network that leads to the broker; the port is chosen when the sandbox is created.
HTTPS_PROXY, HTTP_PROXYThe broker again, so that everything else the sandbox fetches goes through it too. Both are also set in lower-case, because tools disagree about which spelling they read.
NO_PROXYThe broker's own address, so a request to a route is not itself sent through the proxy.

The plain address is http://, not https://. That hop never leaves your computer: it runs from the virtual machine to a port on the loopback interface of the same machine. The broker makes the connection to the upstream over TLS, checked against the upstream's own name.

A request to $ECHO_URL/headers with the placeholder asked the upstream to report what it received:

"Authorization": "Bearer real-secret-xyz-0123456789"

The upstream saw the real key. The sandbox that sent the request never had it.

What the broker refuses

A refusal is an ordinary HTTP response, so the agent's tool fails immediately with a status it understands. The body is one line that starts kosa8: and says what to change, for example kosa8: this route needs the sandbox's placeholder credential. The same decision goes to the audit log with a short, stable reason.

RequestStatusReason in the audit log
Placeholder missing or wrong403 Forbiddenplaceholder credential missing or wrong
Path outside the route's prefixes403 Forbiddenpath not allowed on route
Method not on the route's list405 Method Not Allowedmethod not allowed on route
Placeholder also in the URL400 Bad Requestplaceholder also sent in the URL
A . or .. in the path400 Bad Requestpath has a . or .. segment
An encoded dot or slash, or \400 Bad Requestpath has an encoded dot or slash, or a backslash
A route name that does not exist404 Not Foundno such route
TRACE, on anything405 Method Not AllowedTRACE is never forwarded
A host that is not listed403 Forbiddenhost not in allow-list

For a route the checks run in this order: the path's spelling, the method, the path's prefix, then the placeholder. Paths with two possible readings are refused instead of tidied up, because the broker cannot know which reading the upstream would take. TRACE is refused because its purpose is to echo the request back, key included.

Each of the first five rows was produced by a real request. These are the entries they left:

sandbox.egress.allow {"kind":"route","host":"httpbin.org","port":443,"method":"GET","path":"/headers","route":"echo","status":200,"bytes_in":225}
sandbox.egress.deny  {"kind":"route","host":"httpbin.org","port":443,"method":"GET","path":"/headers","route":"echo","reason":"placeholder credential missing or wrong"}
sandbox.egress.deny  {"kind":"route","host":"httpbin.org","port":443,"method":"GET","path":"/anything","route":"echo","reason":"path not allowed on route"}
sandbox.egress.deny  {"kind":"route","host":"httpbin.org","port":443,"method":"POST","path":"/get","route":"echo","reason":"method not allowed on route"}
sandbox.egress.deny  {"kind":"route",...,"reason":"placeholder also sent in the URL"}
sandbox.egress.deny  {"kind":"route",...,"path":"/get/../anything","reason":"path has a . or .. segment"}

An entry carries the path and never the query string, which is where keys and personal data tend to travel. Entries are limited to 120 a minute for each sandbox and each kind of request (refused, credentialed, other allowed), so ordinary traffic cannot crowd out a credentialed call or a refusal; past that, requests are counted and the count rides on the next entry. See The audit log.

Where the real key lives, and for how long

PlaceIs the real key there?
Your shell, and kosa8 agent runyes It is read from the environment variable the kit names, and sent to the service over a socket only you can open.
The kosa8d service's memoryyes For as long as the sandbox runs.
The sandboxno It holds the placeholder.
The sandbox's record on diskno The record names the routes and hosts, never a credential or a placeholder.
A snapshotno
The audit logno
Responses from the brokerno Not in a response it writes, not in an error. A response header from the upstream that echoes the key is dropped.

When the sandbox stops, its broker stops and the key is dropped from memory. Nothing wrote it down, so nothing can bring it back.

A brokered sandbox does not come back brokered

Stop a brokered sandbox and start it again, restore it from a snapshot, fork it, or restart the service, and it comes up with its network still closed and no broker. A sandbox made by kosa8 agent run can then reach nothing. That is the safe direction: carrying the key across would mean storing it. kosa8 agent run makes a fresh sandbox every time for this reason. To continue work, run the kit again on the same folder.

Limits

What this does not do

  • The placeholder is a capability. The agent cannot learn your key, and it can spend it: any request the route's methods and paths admit is made with the real credential, for as long as the sandbox runs. The broker cannot tell a good request from a bad one. Narrow the route, and scope the key itself at the provider.
  • Bodies are not inspected. An upstream that echoes the key back in a response body returns it to the agent. Only headers are checked. Request bodies to an allowed destination can carry anything the agent can read.
  • The check for a misplaced placeholder is literal. The broker looks for the placeholder in other headers and in the URL as plain text. An agent that encodes it first is not caught, and loses nothing but a placeholder.
  • It needs a tool that accepts a base URL. One that does not, such as the Copilot CLI, cannot be brokered and has its key passed in.
  • The placeholder is not authentication against someone who can read the sandbox. It keeps other programs on your computer from using the broker's port as a free key. Anything inside the sandbox has it.
  • Other hosts go through an opaque tunnel. Method and path limits apply to routes and to plain HTTP. HTTPS to a host in egress_allow is a tunnel the broker does not look inside. See Network rules.
  • No WebSocket on a route. HTTP Upgrade is not forwarded on routes or plain HTTP.
  • The key is in memory on your computer. A program running as you, or as an administrator, is outside what this protects against.

Where to go next