Skip to content

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

Draft
Stener1 wants to merge 3 commits into
cosscom:mainfrom
Stener1:secret-request-card
Draft

Stener1 wants to merge 3 commits into
cosscom:mainfrom
Stener1:secret-request-card

Conversation

@Stener1

@Stener1 Stener1 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

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)
Loading
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 card Live: after Save, the agent's report
live card live saved
Card (mock) Production card (mock)
card production
Settings → Secrets Production delete needs the name
settings delete 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 as Where it saves them
picker settings

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

@vercel

vercel Bot commented Oct 30, 2026

Copy link
Copy Markdown

@Stener1 is attempting to deploy a commit to the seanbrydonio's projects Team on Vercel.

A member of the Team first needs to authorize it.

@Stener1

Stener1 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Could a maintainer add run-ci? Stener1 can't add labels here (GraphQL permission error). The checks AGENTS.md lists were run locally: go vet ./... && go test -race ./..., the app's checks + pnpm test + build, npx tsc -p e2e and the docs build, all green.

@Stener1
Stener1 force-pushed the secret-request-card branch from 2a1a4e8 to e2ec3ce Compare November 4, 2026 13:33
Stener1 and others added 3 commits November 4, 2026 13:34
…nce, never the value (BRA-2860)

An agent runs `berthd secret request NAME --reason …` (or the
berth_secret_request tool); its session reads as needing you and a masked
card shows in the chat, on the dashboard and as a notification. Save
securely sends the value from the app to the box in one call the box's own
socket can't make, into a store behind one provider interface: Local
(Keychain, Secret Service, or an encrypted file) or GCP Secret Manager.
The agent hears back with box://NAME or gcp://… and uses it in one command
with `berthd secret exec --env` or `--file`. Settings → Secrets lists and
deletes saved secrets and sets where they go, per box and per project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… agent can't move where secrets go

From the review: a save claims its request, so a decline, the timeout or
a second save can't settle it while the value is written; the expiry
sweep looks again under the stores' lock, so a replacement saved since is
never deleted; an expired secret is gone from memory too; a project's
store can't be changed through the location config from the box's own
socket, and a config written without it keeps it; resetting a project to
the box's default in Settings clears its choice; secret-tool's newline is
trimmed. Also: report cards name the secret, the CLI and the MCP tool
share one wait, and gcloud is found where it installs itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vate project from the card (BRA-2860)

A name template in Settings → Secrets, for the box or one project, says
what Secret Manager calls a secret ({NAME}_{SERVICE}_{ENV}); the agent
keeps the plain name, and each environment is its own secret. A private
project set once in Settings is offered on the card with the box's other
projects, and the person picks at save time. Also fixes a project's own
store being ignored for agents in its worktrees, and the live script
grepping the wrong folder for the agent's transcript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRBpGMdZnu8NsP1S8UGDRj
@Stener1
Stener1 force-pushed the secret-request-card branch from e2ec3ce to 16e9a53 Compare November 4, 2026 13:34

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant