Secrets in Boat Boxes
Boat runs Boxes: cloud VMs with a
full Ubuntu userland, a disk that survives stop/resume, SSH, a streamed
desktop, and six coding-agent harnesses (Claude Code, Codex, pi, OpenCode, Prime
Agent, Kimi Code) preinstalled and driven by box prompt.
Most Boxes therefore run code you did not write, so the usual advice for agent workloads applies: prefer keeping the credential outside to injecting it. Boat assumes the same — it marks new accounts safe for third parties by default.
Boat's own secrets store
Dashboard > Environment holds environment variables and secret files, injects them into every Box started from that environment, and mints an immutable version on each change. Using it means keeping a second plaintext copy of every credential:
| Boat environment | Resolve, then inject | |
|---|---|---|
| Where the plaintext sits | Boat's backend, plus every Box on that environment | Your process, for the length of one create call |
| Who can read it back | Anything holding a key with environment.read: GET /secrets returns envContents and every secret file's contents verbatim | Nothing without the token's private key — /v1/resolve answers with ciphertext |
| A rotation reaches | Boxes started afterwards; running Boxes only on box env upgrade | The next Box you create, because the value is fetched at create time |
| Scope | Per environment, for every Box that uses it | Per Box, per name — the set you passed |
| Two stores to rotate | Yes | No |
Two stores means two rotations. Use seekrit for credentials, and Boat's environment for what isn't one: repository lists, feature flags, base URLs.
--environment and --env are unrelated. --environment staging picks
which template a Box starts from. --env KEY=value sets one variable on that
one Box. Everything below uses the second.
Inject at create
Resolve on your machine and pass only the names you need. The Box never holds a seekrit token.
# CLI — one Box, two names.
seekrit run --app storefront --env production -- \
sh -c 'box new --no-env -e OPENAI_API_KEY="$OPENAI_API_KEY" -e TAVILY_API_KEY="$TAVILY_API_KEY"'
import { BoxApi, Configuration } from "@asciidev/box-sdk";
import { Seekrit } from "@seekrit/sdk";
const secrets = await new Seekrit({ token: process.env.SEEKRIT_TOKEN }).resolve();
const boat = new BoxApi(new Configuration({
basePath: "https://ascii.dev/api/box/v1",
accessToken: process.env.BOX_API_KEY!,
}));
const created = await boat.create({
createBoxRequest: {
ttlSeconds: 3600,
noEnv: true,
env: {
OPENAI_API_KEY: secrets.OPENAI_API_KEY,
TAVILY_API_KEY: secrets.TAVILY_API_KEY,
},
},
});
import os, seekrit
from ascii_box_sdk import ApiClient, Configuration
from ascii_box_sdk.api.box_api import BoxApi
from ascii_box_sdk.models.create_box_request import CreateBoxRequest
secrets = seekrit.Client(token=os.environ["SEEKRIT_TOKEN"]).resolve()
config = Configuration(host="https://ascii.dev/api/box/v1", access_token=os.environ["BOX_API_KEY"])
with ApiClient(config) as client:
box = BoxApi(client)
created = box.create(CreateBoxRequest(
ttl_seconds=3600,
no_env=True,
env={
"OPENAI_API_KEY": secrets["OPENAI_API_KEY"],
"TAVILY_API_KEY": secrets["TAVILY_API_KEY"],
},
))
Named keys, not the whole dict — see Pick names, not the whole
environment. Boat
caps env at 100 variables and 64KB per Box, rejects names outside
[A-Za-z_][A-Za-z0-9_]*, and refuses its own reserved ones (ASCII_TOKEN,
BOX_ID).
Pair it with --no-env
noEnv: true withholds everything else Boat would pass in: your dashboard
variables and secret files, the GitHub token and gh login, the model
credentials from the Agents tab, the in-Box Boat CLI token, and the SSH
identity. Per-Box env still arrives, so the Box holds the values you resolved
and nothing else.
Boat documents the same combination for per-user keys (box new --no-env -e ANTHROPIC_API_KEY=…); seekrit supplies the value in place of a paste box in
your UI.
For more than the occasional Box, mark a whole environment safe for third parties instead. It survives forks and resumes; a flag on one create call does not.
What persists
A Box is not ephemeral. Three Boat features carry injected values forward:
| What carries | What it means | |
|---|---|---|
| Environment versions | A Box takes the version current at start and keeps it for life | A secret added after a Box started is not in that Box. box info reports environmentVersion; box env upgrade moves it |
| Forks and templates | Per-Box env carries into every fork, and into every deploy from a box snapshot name | Boat's docs say not to put a secret in --env on a Box you will save as a template. One injected key otherwise appears in every Box built from it |
| Snapshots | The whole filesystem, readable later with getSnapshotFile while the Box is stopped or deleted | A .env written inside a Box stays downloadable from the snapshot API after the Box is gone, and a rotation does not reach it |
Upgrading is one-way on disk: a value the new version withholds is deleted from the machine, not hidden, and re-pinning does not restore it.
So:
- Short-lived Box you drive yourself: inject at create. Nothing is written to disk, and the Box outlives the values by minutes.
- Long-lived Box, fork parent, or template: give it one scoped
skt_token and resolve at process start, or keep the credential off the machine.
A Box you will save as a template with box snapshot should hold no
credential, including the seekrit token — the token carries into every deploy
from that name like any other env value. Inject it when you create from the
template instead.
box exec takes no environment
box exec and the command() API accept a command, a cwd and a timeout.
There is no per-command env as on E2B or Daytona, so values can only arrive at
Box creation or from the account environment.
Do not work around it on the command line:
# The value lands in argv, in the API request, and in any log of either.
box exec "$box_id" "STRIPE_KEY=sk_live_… ./deploy.sh"
Boat's own guidance says the same — no secrets in prompts, URLs, or CLI arguments that may be logged. If a command needs a value the Box was not created with, resolve inside the Box.
Resolve inside the Box with seekrit-run
Give the Box one credential — a service token scoped to a single environment — and let each process fetch what it needs at start. A setup file installs the launcher:
cat > setup.sh <<'EOF'
#!/bin/bash
curl -fsSL https://run.seekrit.dev/install.sh | sh
EOF
# The token goes to stdout on its own (the confirmation goes to stderr),
# so it can be captured without being echoed.
skt="$(seekrit token create --name box-$(date +%s) --app storefront --env production)"
box new --no-env --setup-file ./setup.sh -e SEEKRIT_TOKEN="$skt"
Setup files run in the background and do not delay ready, so wait for
setupStatus: done in box info before the first command that needs the
launcher:
box exec "$box_id" --cwd storefront -- seekrit-run --cache -- ./scripts/migrate.sh
seekrit-run fetches ciphertext, decrypts it inside the
Box, and execs the command with the values set. Nothing is written to disk,
--cache avoids starting uncredentialed when a resolve races a network failure,
and a rotation lands on the next command rather than requiring a new Box.
Keep the token in per-Box env rather than a secret file: env values live in
the process environment, while a file lands on the disk and in every snapshot.
Do not put an admin token on a Box. It is
org-scoped and can mint more tokens. Mint one skt_ per Box, scoped to one
application and environment, so seekrit token revoke at teardown is a
complete cutoff. seekrit token list shows last-used time, which is how a Box
you forgot to delete becomes visible.
Keep the credential outside the Box
For a Box running an agent, injection has the usual problem: a process that can
read os.environ can exfiltrate what it finds there.
Boat has no egress policy, so run seekrit-proxy on
your own machine and reach it from the Box over a reverse tunnel. The credential
stays on your host.
# On your machine: config with names, not values, then the proxy.
seekrit proxy init --mode forward --preset anthropic --preset openai --unmatched deny
export SEEKRIT_TOKEN=skt_…
seekrit-proxy --config seekrit-proxy.toml
# A port on your machine becomes 127.0.0.1:8081 inside the Box.
box forward "$box_id" --reverse --local 8081
The tunnel rides the same SSH session as box ssh and listens on loopback
inside the Box, so nothing is published. Point the Box at it at create time,
with a placeholder in place of the key:
box new --no-env \
-e HTTPS_PROXY=http://127.0.0.1:8081 \
-e NODE_EXTRA_CA_CERTS=/home/user/seekrit-proxy-ca.pem \
-e SSL_CERT_FILE=/home/user/seekrit-proxy-ca.pem \
-e ANTHROPIC_API_KEY='{{seekrit:ANTHROPIC_API_KEY}}'
Copy the CA's public certificate in once — box scp ./seekrit-proxy-ca.pem "$box_id":/home/user/ — and keep the key on your machine. The agent holds a
string that works only through the proxy, the methods and paths in each rule
bound what the real key can be used for, and the proxy redacts injected values
out of responses so an
error body cannot return the key to the agent's context.
HTTPS_PROXY is a convention, not enforcement. Sprites has a DNS policy
and Cloudflare has outbound handlers; a Box has neither, and the firewall
box host opens governs traffic to the Box. An agent that ignores the
variable reaches the internet directly — without a credential, but also
without a rule. What this buys is that the agent's environment, logs, crash
dumps and snapshots hold a placeholder rather than a key. Use
--unmatched deny for the traffic that does go through, and treat it as
credential hiding rather than containment.
Do not publish the proxy with box host. It opens the Box firewall and
registers an HTTPS subdomain, and seekrit-proxy has no inbound
authentication — a public route to it relays credentials for anyone who finds
the URL.
Pointing the built-in harnesses at it
Forward-proxy mode suits Boat because the harnesses treat base URLs differently:
| Harness | Base-URL override | Notes |
|---|---|---|
| Claude Code | ANTHROPIC_BASE_URL | Works in reverse mode too — set it to the route prefix and pass the placeholder as ANTHROPIC_API_KEY |
| Codex | Ignored | Boat's agent server configures Codex with a model_providers block rather than OPENAI_BASE_URL, which is how its DeepSeek and Bedrock paths work. You can write that block from a setup file; HTTPS_PROXY avoids needing to |
| pi, OpenCode, Prime Agent, Kimi Code | Varies | One proxy variable covers all of them |
For your own process rather than box prompt, reverse mode works and needs no
CA: seekrit proxy init --preset openai and one OPENAI_BASE_URL.
For a fleet
box forward runs in the foreground on one machine, which suits development
rather than a backend creating Boxes for users. There, run the proxy on
infrastructure you control and make it reachable from the Box over a private
network, behind mTLS, or behind an IP allowlist — it has no inbound
authentication of its own. Give each Box its own agent
identity so the rules are published and signed
rather than copied per tenant.
Boat's own API key
BOX_API_KEY is a credential too, and Boat's keys take scopes and TTLs. Mint a
narrow one and store it:
box api-key create orchestrator --preset ci --ttl 90d --json \
| jq -r .secret \
| seekrit secrets set BOX_API_KEY - --app platform --env production
Your orchestrator then reads it at start:
seekrit run --app platform --env production -- node ./provision-boxes.js
The key Boat writes inside a Box is already scoped to that Box: it can exec,
read and write files, SSH and snapshot itself, but cannot create, resume, fork
or delete Boxes, or touch the account. --no-env withholds it entirely.
Teardown
Delete the Box and revoke its token:
box delete "$box_id"
seekrit token revoke skt_XXXXXXXX
box delete is permanent and removes the snapshots only that Box uses.
box stop keeps the disk, and the snapshots with it.
Checklist
- Credentials in seekrit, not duplicated into Boat's Environment tab
- Boxes created with
--no-env, or on an environment marked safe for third parties - Values resolved on the host and passed as named
enventries, not the whole environment - No secret in
--envon a Box that will be forked or saved as a template - No secret on a
box execcommand line - Long-lived Box: one scoped
skt_token plusseekrit-run, never an admin token - Agent Box:
seekrit-proxyon your machine behindbox forward --reverse, placeholders inside - Proxy never published with
box host -
BOX_API_KEYscoped with a preset and a TTL, and stored in seekrit - Teardown revokes the token in the same step that deletes the Box
See also
- Agent sandboxes — the two shapes and when each applies
- Fly.io Sprites — the other persistent sandbox
seekrit-runlauncher — flags, caching, and degradation behaviour- Credential broker — full proxy configuration
- Agent access policy — which hosts and operations an agent may reach
- Service tokens — scoping what a token can read
- Rotation — what a rotation does and does not revoke