seekrit
Docs/Boat

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 environmentResolve, then inject
Where the plaintext sitsBoat's backend, plus every Box on that environmentYour process, for the length of one create call
Who can read it backAnything holding a key with environment.read: GET /secrets returns envContents and every secret file's contents verbatimNothing without the token's private key — /v1/resolve answers with ciphertext
A rotation reachesBoxes started afterwards; running Boxes only on box env upgradeThe next Box you create, because the value is fetched at create time
ScopePer environment, for every Box that uses itPer Box, per name — the set you passed
Two stores to rotateYesNo

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.

# 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 carriesWhat it means
Environment versionsA Box takes the version current at start and keeps it for lifeA secret added after a Box started is not in that Box. box info reports environmentVersion; box env upgrade moves it
Forks and templatesPer-Box env carries into every fork, and into every deploy from a box snapshot nameBoat'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
SnapshotsThe whole filesystem, readable later with getSnapshotFile while the Box is stopped or deletedA .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.
caution

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.

caution

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.

caution

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.

caution

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:

HarnessBase-URL overrideNotes
Claude CodeANTHROPIC_BASE_URLWorks in reverse mode too — set it to the route prefix and pass the placeholder as ANTHROPIC_API_KEY
CodexIgnoredBoat'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 CodeVariesOne 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 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