feat: agents ask for a secret through a masked card, and get a reference, never the value (BRA-2860)

Draft
ST
5d ago
+6104-56

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 exec and Settings only go through SecretStore; TestAThirdProviderPlugsInFromItsOwnFile registers 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 cardLive: after Save, the agent's report
live cardlive saved
Card (mock)Production card (mock)
cardproduction
Settings → SecretsProduction delete needs the name
settingsdelete locked

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 asWhere it saves them
pickersettings

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

https://claude.ai/code/session_01QRBpGMdZnu8NsP1S8UGDRj