# Secrets in Boat Boxes

[Boat](https://docs.boat.dev/box/quickstart) 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](/docs/guides/sandboxes) to injecting it. Boat assumes the same — it
marks new accounts [safe for third
parties](https://docs.boat.dev/box/environments#safe-for-third-parties) by
default.

## Boat's own secrets store

[Dashboard > Environment](https://docs.boat.dev/box/environments) 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`](/docs/concepts/encryption) 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.

> **Note:** **`--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.

```bash
# 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"'
```

```ts
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,
    },
  },
});
```

```python
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](/docs/guides/sandboxes#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.

> **Warning:** 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:

```bash
# 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:

```bash
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:

```bash
box exec "$box_id" --cwd storefront -- seekrit-run --cache -- ./scripts/migrate.sh
```

[`seekrit-run`](/docs/guides/run) fetches ciphertext, decrypts it inside the
Box, and `exec`s 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.

> **Warning:** **Do not put an [admin token](/docs/guides/service-tokens) 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`](/docs/guides/agent-proxy) on
your own machine and reach it from the Box over a reverse tunnel. The credential
stays on your host.

```bash
# 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
```

```bash
# 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:

```bash
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](/docs/guides/agent-proxy#the-response-on-the-way-back) so an
error body cannot return the key to the agent's context.

> **Warning:** **`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.

> **Warning:** **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](/docs/guides/agent-proxy/policy) 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:

```bash
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:

```bash
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:

```bash
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 `env` entries, not the whole environment
- [ ] No secret in `--env` on a Box that will be forked or saved as a template
- [ ] No secret on a `box exec` command line
- [ ] Long-lived Box: one scoped `skt_` token plus `seekrit-run`, never an admin token
- [ ] Agent Box: `seekrit-proxy` on your machine behind `box forward --reverse`, placeholders inside
- [ ] Proxy never published with `box host`
- [ ] `BOX_API_KEY` scoped 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](/docs/guides/sandboxes) — the two shapes and when each applies
- [Fly.io Sprites](/docs/guides/sandboxes/sprites) — the other persistent sandbox
- [`seekrit-run` launcher](/docs/guides/run) — flags, caching, and degradation behaviour
- [Credential broker](/docs/guides/agent-proxy) — full proxy configuration
- [Agent access policy](/docs/guides/agent-proxy/policy) — which hosts and operations an agent may reach
- [Service tokens](/docs/guides/service-tokens) — scoping what a token can read
- [Rotation](/docs/concepts/rotation) — what a rotation does and does not revoke
