feat: agents ask for a secret through a masked card, and get a reference, never the value (BRA-2860)
Summary
An agent that needs an API key asks for it by name instead of having it pasted into the chat. The person types it into a masked card, the value goes from the app to the box only, and the agent gets a reference (box://NAME, gcp://…), never the value.
sequenceDiagram
participant Agent as Agent (box)
participant Box as berthd
participant App as Shipyard app
Agent->>Box: berthd secret request ACME_TOKEN --reason "…" (box socket, X-Berth-Caller)
Box-->>Agent: request id, at once (or the ref, if saved and unexpired)
Box-->>App: secret.requested · session reads "waiting" (soft) with secret_request
App->>Box: POST secrets/requests/{id}/save {value} (pinned wire only; the box socket is refused)
Box->>Box: SecretStore.Save → Keychain / Secret Service / encrypted file / GCP
Box-->>Agent: <berth-notification> kind="secret" ref="box://ACME_TOKEN"
Agent->>Agent: berthd secret exec --env T=box://ACME_TOKEN -- cmd (or --file)
internal/box/
├── secretstore.go # SecretStore interface, provider registry, index (never a value), settings, sweep
├── secretstore_local.go # Local: Keychain (security -i, hex on stdin) · Secret Service (secret-tool, stdin) · XChaCha20-Poly1305 file
├── secretstore_gcp.go # GCP Secret Manager via the box's gcloud: labels shipyard=true…, refuses unlabelled, single-secret --grant
└── secretrequests.go # requests (1 h timeout), claim/save/decline, list/delete/settings endpoints, notify via Notifier.Tell
cmd/berthd/secret.go # secret exec --env VAR=REF / --file VAR=REF (0600 temp file in XDG_RUNTIME_DIR or /dev/shm, removed on exit and signals)
internal/boxcmd/secretcmd.go, internal/mcpserver/mcp.go # berthd secret request · berth_secret_request
app: secret-request-card.tsx (chat + dashboard card), settings → Secrets, lib/secret-request.ts
- Providers are one file each. The card, the endpoints,
secret execand Settings only go throughSecretStore;TestAThirdProviderPlugsInFromItsOwnFileregisters a fake third provider from its test file and runs requests, resolution, listing and delete through it. - Store choice: the request's
--store, then the project's choice (box-local location config, never the repo's file), then the box default (Settings → Secrets). - Name template (GCP): Settings → Secrets, for the box or a project, says what Secret Manager calls a secret. The agent keeps the plain name; each environment is its own secret.
template {NAME}_{SERVICE}_{ENV} {name}-{service}-{env} (none)
FRONT_KEY, project shop-api, staging
FRONT_KEY_SHOP_API_STAGING front-key-shop-api-staging FRONT_KEY
- Project picker: when the store is GCP and the box names more than one project, the card offers them, with a private project set once in Settings. The person picks at save time; an agent can't. A secret sent elsewhere is found again only by the same project in the same environment.
- Docs:
guides/secrets.mdx(asking, using, where they're kept, GCP minimum role, adding a provider), security page reworded where it said no value is written to disk, API/CLI/events references, changelog. The berth agent skill tells agents to ask rather than have keys pasted.
Evidence
Go: go vet ./... && go test -race ./... green. New: save refused on the box socket, request only from an agent session, sentinel absent from events and state files, reuse/--new/1 h timeout on a fake clock, the session hearing back in its own terminal, claim vs decline/timeout, an agent can't move a project's store via location config, encrypted file round-trip with 0600/0700 and a separate key, Keychain and Secret Service against fakes with the value never in argv, Secret Service fallback, expiry sweep and expiry over the cache, GCP against a fake gcloud (4 labels, refuses unlabelled, new version on labelled, single-secret binding, names the missing permission), secret exec --env/--file and the temp file going on SIGTERM, MCP tool returns at once. App: typecheck, titles, type, grid, csp, themes, 642 unit tests, build, e2e tsc; Playwright a11y scenes for the card and Settings; docs build.
Live, real Claude on a dev box (.claude/skills/verify-berthd/scripts/secret-request-live.sh), terminal and structured: the agent runs berthd secret request ACME_TOKEN, the app saves a random sentinel through the card, the agent's secret exec --env and --file sha256 checks pass, the temp file is gone, and the sentinel greps to 0 hits in the agent's transcript, the structured host log, the box log, events, turns.json, inbox.json, run records, notify.json and the laptop's queue.json.
1. the agent asks for ACME_TOKEN request sr_…
2. the person saves it (drive.mjs secret-request-live: card → Save securely; page holds no trace of the value)
3. the agent hears back, uses it --env and --file both matched; /run/user/…/berth-secret-…/F is gone
4. the sentinel is nowhere it must not be 0 hits
| Live: the agent's card | Live: after Save, the agent's report |
|---|---|
![]() | ![]() |
| Card (mock) | Production card (mock) |
|---|---|
![]() | ![]() |
| Settings → Secrets | Production delete needs the name |
|---|---|
![]() | ![]() |
More: board cards · revealed · saved · refused save keeps the value in the field · typed name. Scenarios: drive.mjs secret-request, settings-secrets (mock), secret-request-live (dev box).
| Project picker and Saved as | Where it saves them |
|---|---|
![]() | ![]() |
Real boxes: on a build of this branch in the Mac app, on a Linux box: the card saved through the app, secret exec --env and --file matched the value's sha256, the temp file went, Settings listed and deleted it, and deleting from the box socket was refused (403). On a Mac in its GUI session, the local store's save, read and delete worked against the real login Keychain; over SSH the Keychain refuses, and the store says so. A full card round on a Mac box wasn't run. The name template and project picker are not on a real box yet: they're proven by Go tests against a fake gcloud, the mock scenarios, and a live local-store regression run.
Not run live: GCP Secret Manager (no scratch project), so the labels, --grant, template and picker are covered by the fake-gcloud tests only.
Devbox baselines: TestTheCatalogIsCapped and TestPaneDeadOfASignalIsItsShellStatus fail in the full run here; the first counts the machine's real commands, the second is a timing test under load, and neither touches this change.
Review: Codex review + adversarial review, one fix pass (second commit): a save claims its request so a decline/timeout/second save can't race it; the sweep rechecks under the stores' lock so a replacement isn't deleted; expiry is enforced ahead of the in-memory cache; the location config endpoint can't change a project's store from the box socket and keeps it when omitted; Settings' "Box default" clears a project's choice; secret-tool's trailing newline. A second adversarial pass on the template and picker found a private secret could be reused across projects and environments, and a third project's wasn't found for rotation; both fixed with tests. Also fixed: a project's own store was ignored for agents in its worktrees (session location is project/worktree), and the live script was grepping the wrong folder when CLAUDE_CONFIG_DIR is set, so it now fails if the agent's transcript isn't there. Left as a follow-up: the secret wait is an overlay on the session list (enrich), not a ledger state, because a request outlives the turn that made it; so a parent agent watching the child doesn't get a "waiting" report for it.
Merge Danger
Door: two-way
Additive: new endpoints, ref schemes (box://, gcp://), a Settings section and new state files under the box's state dir (secrets/, secret.key, secret-requests.json), no migration. Reverting is a plain revert; secrets already saved stay in the Keychain / Secret Service / encrypted file until deleted (the guide says how). GCP side effects are a labelled secret and an optional single-secret IAM binding, both deletable; berthd never touches project IAM or unlabelled secrets.
Blast Radius: secrets
box:///gcp:// are now recognised as references wherever op:// is (project env, env.json), and a session with a pending request shows as waiting (soft: it still takes prompts). go.mod: golang.org/x/crypto moves from indirect to direct (same version).
🤖 Generated with Claude Code







