From 9322de04cd6f7ea1f4ed3c4207a7137534219ef4 Mon Sep 17 00:00:00 2001 From: Stener1 Date: Thu, 29 Oct 2026 22:42:55 +0000 Subject: [PATCH 1/3] feat: agents ask for a secret through a masked card, and get a reference, never the value (BRA-2860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../skills/verify-berthd/features/README.md | 2 + .../verify-berthd/features/secret-request.md | 39 + .../features/settings-secrets.md | 29 + .../scripts/scenarios/secret-request-live.mjs | 42 + .../scripts/scenarios/secret-request.mjs | 159 ++++ .../scripts/scenarios/settings-secrets.mjs | 95 +++ .../scripts/secret-request-live.sh | 111 +++ app/e2e/a11y-scenes.ts | 10 +- app/src/components/command-palette.tsx | 1 + .../conversation/conversation-pane.tsx | 21 +- .../components/conversation/report-card.tsx | 11 +- .../conversation/secret-request-card.tsx | 245 ++++++ app/src/components/sidebar/rail.tsx | 3 +- app/src/lib/api.ts | 82 ++ app/src/lib/events.ts | 32 +- app/src/lib/mock-secrets.ts | 160 ++++ app/src/lib/mock.ts | 7 + app/src/lib/notifications.ts | 7 +- app/src/lib/secret-request.test.ts | 182 +++++ app/src/lib/secret-request.ts | 151 ++++ app/src/lib/transcript.ts | 2 +- app/src/views/dashboard/agent-card.tsx | 9 +- app/src/views/home/widgets/agents.tsx | 4 +- app/src/views/settings/find.tsx | 1 + app/src/views/settings/open.ts | 2 +- app/src/views/settings/secrets-section.tsx | 269 +++++++ app/src/views/settings/settings-view.tsx | 6 +- cmd/berthd/main.go | 8 +- cmd/berthd/secret.go | 139 +++- cmd/berthd/secret_test.go | 114 +++ docs/changelog.mdx | 11 + docs/concepts/security.mdx | 19 +- docs/guides/secrets.mdx | 140 +++- docs/reference/app-api.mdx | 8 + docs/reference/berthd.mdx | 5 +- docs/reference/cli.mdx | 2 +- docs/reference/events.mdx | 4 + go.mod | 2 +- internal/box/api.go | 4 + internal/box/client.go | 12 + internal/box/main_test.go | 4 + internal/box/notify.go | 16 + internal/box/notify_text.go | 20 + internal/box/repoconfig.go | 7 + internal/box/scripts.go | 4 + internal/box/secretrequests.go | 760 ++++++++++++++++++ internal/box/secretrequests_test.go | 474 +++++++++++ internal/box/secrets.go | 34 +- internal/box/secretstore.go | 439 ++++++++++ internal/box/secretstore_gcp.go | 232 ++++++ internal/box/secretstore_local.go | 479 +++++++++++ internal/box/secretstore_test.go | 574 +++++++++++++ internal/box/sessions.go | 3 + internal/box/tasks.go | 11 + internal/boxcmd/boxcmd.go | 5 +- internal/boxcmd/boxcmd_test.go | 20 + internal/boxcmd/secretcmd.go | 79 ++ internal/integrations/skills/berth/SKILL.md | 23 + internal/mcpserver/mcp.go | 42 +- internal/mcpserver/mcp_notify_test.go | 27 + internal/transcript/report.go | 2 +- 61 files changed, 5352 insertions(+), 53 deletions(-) create mode 100644 .claude/skills/verify-berthd/features/secret-request.md create mode 100644 .claude/skills/verify-berthd/features/settings-secrets.md create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/secret-request-live.mjs create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/secret-request.mjs create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/settings-secrets.mjs create mode 100755 .claude/skills/verify-berthd/scripts/secret-request-live.sh create mode 100644 app/src/components/conversation/secret-request-card.tsx create mode 100644 app/src/lib/mock-secrets.ts create mode 100644 app/src/lib/secret-request.test.ts create mode 100644 app/src/lib/secret-request.ts create mode 100644 app/src/views/settings/secrets-section.tsx create mode 100644 internal/box/secretrequests.go create mode 100644 internal/box/secretrequests_test.go create mode 100644 internal/box/secretstore.go create mode 100644 internal/box/secretstore_gcp.go create mode 100644 internal/box/secretstore_local.go create mode 100644 internal/box/secretstore_test.go create mode 100644 internal/boxcmd/secretcmd.go diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 11a07f637..d7db8b8e5 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -22,6 +22,8 @@ The maintained list of what a person can do in the app, and how to prove each on | Chat's foot and reply box: a live turn's spinner, timer, Stop and Queue; a turn that ended while its work runs on shows an hourglass and Send | [chat-turn-state.md](chat-turn-state.md) | `chat-turn-state`; `chat-turn-state-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` asserts it | | Chat and board: no question card or answers but the options the agent's screen shows; the mode menu while turn check waits; a structured agent's form while its helpers report back, and a reply box that answers a card with nothing to pick | [chat-ask-card.md](chat-ask-card.md) | `chat-ask-card`, `chat-structured-ask-live` (a real box, `--agent`); `app/e2e/ask-card.spec.ts` on a stand-in box | | The board's cards for agents in chat: what they ask with its answers, their reply's last lines, the prompt they started with; never the host's frames or "stdio" | [chat-board-cards.md](chat-board-cards.md) | `chat-board-cards`; `app/e2e/dashboard-chat-cards.spec.ts` asserts it | +| An agent asks for a secret: the card in its chat and on the board (masked field, reveal, expiry, production's warnings and no expiry), Save securely in one POST and then the value nowhere, a refused save keeping it in the field only, Decline, the notification | [secret-request.md](secret-request.md) | `secret-request`; `app/src/lib/secret-request.test.ts` | +| Settings › Secrets: each box's saved secrets by name and facts, never a value; a production delete needs the name typed; where a box and a project save them | [settings-secrets.md](settings-secrets.md) | `settings-secrets` | | Worktrees changed with git outside Shipyard: added, committed in, switched, locked, removed; a new remote; a quiet box runs no git for them | [worktree-git.md](worktree-git.md) | `worktree-git-live` (a real box, `--agent`); `TestListingLocationsAgainRunsNoGitUntilSomethingChanges` | | Turn check's "working": an hourglass with why in the sidebar, the tab and the rail's card, not a live turn's spinner; a handoff reads as done | [turn-check-indicator.md](turn-check-indicator.md) | `turn-check-indicator`; `app/e2e/sidebar-turn-check.spec.ts` asserts it, `TestTurnCheckLive` asks the real provider | | Upgrading a box: sessions keep running, and nothing the old build started stays a zombie under berthd | [box-upgrade.md](box-upgrade.md) | `../scripts/upgrade-zombies.sh FROM TO` (a real box, Linux); `go test ./internal/childreap` | diff --git a/.claude/skills/verify-berthd/features/secret-request.md b/.claude/skills/verify-berthd/features/secret-request.md new file mode 100644 index 000000000..4a9773ee3 --- /dev/null +++ b/.claude/skills/verify-berthd/features/secret-request.md @@ -0,0 +1,39 @@ +# An agent asks for a secret + +An agent that needs an API key or a token asks the person for it with `berthd secret request NAME --reason "…"`. The box holds the request on the agent's session (`Session.secret_request`, with `agent_state: "waiting"`, `soft_wait: true` and `ask.tool: "SecretRequest"`) until it is saved, declined or an hour passes. The app draws a **secret card** wherever the agent's question would show (`components/conversation/secret-request-card.tsx`, its state in `lib/secret-request.ts`): + +- **In its chat**, at the foot of the conversation (`conversation-pane.tsx`, the chat's tail), never as Allow / Deny. The reply box says "Give NAME in the card above, never here". +- **On the Agent Dashboard**, in its card in place of its last lines (`views/dashboard/agent-card.tsx`, `compact`). +- **As a notification**: `secret.requested` routes "AGENT asks for a secret", "NAME — reason", in An agent needs you (`lib/events.ts`); `secret.request.ended` settles it (`lib/notifications.ts`). The agent's own `agent.waiting` for it is not a second one. Home's Needs you row and the rail's card say "Asks for the secret NAME". + +The card shows the name (Replace NAME, with when the old one was saved, when `replace`), the reason verbatim, who asks, where it goes (`store_label · where`), the environment, any grant ("Grants … read access to this one secret"), a masked field with a reveal toggle (Show the value / Hide the value), an expiry picker (No expiry, 1 hour, 24 hours, 7 days; the agent's `ttl` preselected), and "The agent can use this in commands. A command that prints it puts it in the chat." **Production** shows the environment, project and grant in the warning colour (`text-warning-foreground`) and has no expiry picker: it never expires. + +**Save securely** reads the field once and sends it in one request, `POST secrets/requests/{id}/save` with `{ value, expires }`; the box answers with a reference (`box://NAME`, `gcp://projects/P/secrets/NAME`). The field is uncontrolled, so the value is in no React state, prop or attribute while typed, and after the save it is cleared and the card goes. A save the box refuses (or a box away) shows its words on the card, and the value stays in the field only: nothing queues or retries it. **Decline** posts `secrets/requests/{id}/decline`. Both buttons are disabled while either is in flight. + +## Sub-features + +- `secret-card-chat`: the card in the chat, with every field above, and no Allow / Deny. +- `secret-card-board`: the card on the Agent Dashboard's card, shorter (Never / 1h / 24h / 7d). +- `secret-reveal`: the toggle flips the field between `password` and `text`. +- `secret-save`: one POST, then the value is in no DOM node, field, transcript item or storage, and the card goes. +- `secret-production`: warning colour on environment, project and grant; no expiry picker; Replace with when. +- `secret-refused`: the box's words on the card ("Couldn't save it: … The value is still in the field, and nowhere else."), the value in the field, no second POST. +- `secret-decline`: one POST to decline, the card goes, the field with it. +- `secret-notification`: the needs-you notification, settled when the request ends. + +## How to get to it (user POV) + +- An agent runs `berthd secret request ACME_TOKEN --reason "…"`. Its chat, its card on ⌘J's board and a notification show the request. + +## Driving it with drive.mjs + +- On the mock: `scenarios/secret-request.mjs` opens `?mock=1&secrets=1&sidebar=list&view=conversation`. With `?secrets` (`lib/mock-secrets.ts`), devl's search-perf Claude ("Speed up product search") asks for `ACME_TOKEN` (staging, Encrypted file, `ttl: "24h"`, a two-line reason), and gpu's ci-flake Claude ("Move acme billing to the new ledger") asks to replace `ACME_LEDGER_PROD` (production, GCP Secret Manager · acme-prod, a grant, saved 9 days ago). `window.__secrets.announce()` plays their `secret.requested`. +- Second views: `window.__berthCalls` (every box call the app made, by method and path, as the mock counts them) for the one POST, and `window.__secretSaves` (box, request, expiry; never the value) for what reached the box. A value with `denied` in it is refused as GCP refuses a missing permission. +- It asserts the board's two cards, the chat card's every field, that the typed value is in no attribute, the reveal toggle, the notification, the save's one POST with `7d`, that afterwards the value is in no `outerHTML`, field value, chat item or storage, the production card's warning classes and no picker, the refusal keeping the value in the field with no second POST, and Decline. Screenshots: `1-board-cards`, `2-chat-card`, `3-revealed`, `3b-notification`, `4-saved`, `5-production-card`, `6-save-refused`, `7-declined`. +- `app/e2e/a11y-scenes.ts` has `chat-secret-request` (axe over the production card); `app/src/lib/secret-request.test.ts` covers the card's state, the expiry rules and production's. + +## Gotchas + +- `document.documentElement.outerHTML` doesn't hold a field's live value; the scenario reads every `input` and `textarea`'s `.value` too. +- The mock answers a save after 300 ms and ends the request just before, so the card can go before Save's own answer lands. +- A real box is the only proof that the laptop agent and the box keep no copy of the value (no log, no event); the mock can't show it. diff --git a/.claude/skills/verify-berthd/features/settings-secrets.md b/.claude/skills/verify-berthd/features/settings-secrets.md new file mode 100644 index 000000000..d43168118 --- /dev/null +++ b/.claude/skills/verify-berthd/features/settings-secrets.md @@ -0,0 +1,29 @@ +# Settings › Secrets + +The secrets agents asked for and the person saved, per online box, and where each box saves them (`views/settings/secrets-section.tsx`, `GET secrets`, `POST secrets/delete`, `PUT secrets/settings`). Never a value: the box never sends one back, and the page has no field or Show for one. + +- **The list**: each secret's name, its environment (Production in the warning colour), where it is (`store_label · where`), when it expires ("never" for production), when it was saved, and who asked (the session's work, as the box still has it) and why. A store the box couldn't list (`errors`, a GCP project it has no access to) says so in red. +- **Delete**: asks first. A production secret wants its name typed exactly before Delete enables, and sends it as `confirm`; a staging one needs only the confirm. The list reads again on `secret.saved` and `secret.deleted`, from anywhere. +- **Where it saves them**: the box's default store (from `stores`) and, for GCP, its project; and per project (each of the box's locations) its own store and project, or "Box default". One Save per box sends `PUT secrets/settings`. +- Find a setting answers to "api token", "keys", "credentials", "secret manager"; ⌘K has it too. + +## Sub-features + +- `secrets-list`: names and facts only, per box, with the box's listing errors. +- `secrets-delete-production`: Delete disabled until the name is typed exactly; then one `POST secrets/delete`. +- `secrets-delete-staging`: Delete after a confirm. +- `secrets-where`: the default store and a project's own, saved. + +## How to get to it (user POV) + +- Settings (the gear at the sidebar's foot) › Agents › Secrets, or ⌘K "Secrets". + +## Driving it with drive.mjs + +- On the mock: `scenarios/settings-secrets.mjs` opens `?mock=1` (no `?secrets` needed). devl keeps `ACME_STRIPE_TEST` (staging, Encrypted file, expires in 4 days); gpu keeps `ACME_PROD_DB` (production, acme-prod) and `ACME_LEDGER_KEY` (staging, acme-staging), saves to GCP · acme-staging by default and evals to its encrypted file, and can't list acme-legacy (`lib/mock-secrets.ts`). +- It asserts each row's facts and who asked, the listing error, no password field or Show, gpu's store choices, shop given GCP · acme-shop and saved (`PUT gpu/secrets/settings` once, in `window.__berthCalls`), the production delete locked on `ACME_PROD` and `acme_prod_db` and open on `ACME_PROD_DB` (one `POST gpu/secrets/delete`), the staging delete, and Find a setting. Screenshots: `1-secrets-list`, `2-where-saved`, `3-production-delete-locked`, `4-production-delete-typed`, `5-after-deletes`. +- `app/e2e/a11y-scenes.ts` has `settings-secrets`; `app/e2e/look.spec.ts`'s "every Settings section opens" opens it. + +## Gotchas + +- A box without the secrets API answers `GET secrets` with an error; the box's group shows it with `BoxError` (an older berthd gets Update). diff --git a/.claude/skills/verify-berthd/scripts/scenarios/secret-request-live.mjs b/.claude/skills/verify-berthd/scripts/scenarios/secret-request-live.mjs new file mode 100644 index 000000000..3b8f16669 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/secret-request-live.mjs @@ -0,0 +1,42 @@ +// secret-request-live: the save step of ../secret-request-live.sh, in the +// app against a dev box (VERIFY_DRIVER=claude ../dev-box.sh up). The real +// Claude in session "ask" has asked for ACME_TOKEN; this opens its chat, +// finds the card, types the run's sentinel (VERIFY_SECRET_SENTINEL), and +// presses Save securely, as a person would. The card goes, the request is +// saved on the box, and the value is in no DOM node afterwards. See +// ../../features/secret-request.md. +export const live = true; +export const params = { view: "conversation", sidebar: "list" }; + +export async function run({ page, shot, step, expect, call }) { + const sentinel = process.env.VERIFY_SECRET_SENTINEL; + const worktree = process.env.VERIFY_WORKTREE; + if (!sentinel || !worktree) throw new Error("run by ../secret-request-live.sh: VERIFY_SECRET_SENTINEL and VERIFY_WORKTREE"); + + step("open the agent's chat"); + await page.locator(`[data-testid=worktree-row][data-worktree="${worktree}"]`).click({ position: { x: 32, y: 12 } }); + const pane = page.locator("[data-testid=pane]:visible").first(); + await expect(pane.getByTestId("chat")).toBeVisible({ timeout: 30_000 }); + + step("the agent's request shows as a masked card"); + const card = pane.getByTestId("chat").getByTestId("secret-card"); + await expect(card).toBeVisible({ timeout: 60_000 }); + await expect(card.getByTestId("secret-name")).toHaveText("ACME_TOKEN"); + await expect(card.getByTestId("secret-reason")).toHaveText("verify: deploy the acme app to staging"); + await expect(card.getByTestId("secret-store")).toHaveText(/Encrypted file|Keychain|Secret Service/); + const input = card.getByTestId("secret-input"); + await expect(input).toHaveAttribute("type", "password"); + await shot("1-live-card"); + + step("type the value and save it securely"); + await input.fill(sentinel); + await card.getByTestId("secret-save").click(); + await expect(pane.getByTestId("chat").getByTestId("secret-card")).toHaveCount(0, { timeout: 30_000 }); + await shot("2-live-saved"); + + step("the box has it saved, and the page holds the value nowhere"); + const pending = await call("/v1/boxes/devq/api/secrets/requests"); + if (pending.some((r) => r.session === "ask")) throw new Error(`still pending: ${JSON.stringify(pending)}`); + const inPage = await page.evaluate((s) => document.documentElement.outerHTML.includes(s) || [...document.querySelectorAll("input,textarea")].some((el) => el.value.includes(s)) || JSON.stringify({ ...localStorage }).includes(s) || JSON.stringify({ ...sessionStorage }).includes(s), sentinel); + if (inPage) throw new Error("the value is still in the page"); +} diff --git a/.claude/skills/verify-berthd/scripts/scenarios/secret-request.mjs b/.claude/skills/verify-berthd/scripts/scenarios/secret-request.mjs new file mode 100644 index 000000000..7841e4ec1 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/secret-request.mjs @@ -0,0 +1,159 @@ +// An agent asks for a secret (features/secret-request.md): its card on the +// board and in its chat, the masked field and its reveal toggle, Save +// securely (one POST to the box, then the value is in no DOM node, field, +// transcript item or storage), the production card's warnings with no +// expiry picker, a refused save that keeps the value in the field alone, +// and Decline. +// +// On the mock (?secrets: devl's search-perf Claude asks for ACME_TOKEN, a +// staging token in the box's encrypted file; gpu's ci-flake Claude asks to +// replace ACME_LEDGER_PROD in GCP Secret Manager, production, with a grant): +// +// node drive.mjs secret-request --port $PORT + +export const params = { secrets: "1", sidebar: "list", view: "conversation" }; + +const SENTINEL = "acme-sentinel-7f3a9c1e"; +const REFUSED = "acme-denied-2b8d"; +const REASON = "To call the acme staging API from the search benchmark.\nRead-only is enough."; +const HONEST = "The agent can use this in commands. A command that prints it puts it in the chat."; +const WARN = /\btext-warning-foreground\b/; + +// Everywhere a value could be left: the document's markup, every field's +// live value (markup doesn't have it), and the page's storage. +const leftovers = (page, v) => + page.evaluate((v) => { + const where = []; + if (document.documentElement.outerHTML.includes(v)) where.push("outerHTML"); + for (const el of document.querySelectorAll("input, textarea")) if (el.value.includes(v)) where.push(`field ${el.getAttribute("aria-label") ?? el.name ?? el.tagName}`); + if (JSON.stringify({ ...localStorage }).includes(v)) where.push("localStorage"); + if (JSON.stringify({ ...sessionStorage }).includes(v)) where.push("sessionStorage"); + return where; + }, v); + +export async function run({ page, shot, step, expect }) { + const pane = page.locator("[data-testid=pane]:visible"); + const chat = pane.locator("[data-testid=chat]"); + const calls = () => page.evaluate(() => ({ ...window.__berthCalls })); + const saves = () => page.evaluate(() => window.__secretSaves ?? []); + const open = async (worktree) => { + await page.locator(`[data-testid=worktree-row][data-worktree="${worktree}"]`).click({ position: { x: 32, y: 12 } }); + await expect(chat.locator("[data-testid=chat-item]").first()).toBeVisible(); + }; + + step("the Agent Dashboard: both agents in Needs you, each with its secret card in place of its last lines"); + await page.keyboard.press("Meta+j"); + const board = page.getByTestId("dashboard-board"); + await expect(board).toBeVisible(); + const boardCards = board.getByTestId("secret-card"); + await expect(boardCards).toHaveCount(2); + const boardStaging = board.locator("[data-testid=secret-card][data-secret-id=sr-acme-token]"); + await expect(boardStaging.getByTestId("secret-name")).toHaveText("ACME_TOKEN"); + await expect(boardStaging.getByTestId("secret-input")).toHaveAttribute("type", "password"); + await expect(boardStaging).toContainText(HONEST); + await boardStaging.scrollIntoViewIfNeeded(); + await shot("1-board-cards"); + + step("open devl/search-perf's chat: the card with the name, the reason verbatim, who asks, the store, the expiry picker on the agent's 24 hours"); + await open("devl/search-perf"); + const card = chat.getByTestId("secret-card"); + await expect(card).toBeVisible(); + await expect(card.getByTestId("secret-name")).toHaveText("ACME_TOKEN"); + expect(await card.getByTestId("secret-reason").evaluate((el) => el.textContent)).toBe(REASON); + await expect(card.getByTestId("secret-session")).toHaveText("Speed up product search · Claude Code"); + await expect(card.getByTestId("secret-store")).toHaveText("Encrypted file"); + await expect(card.getByTestId("secret-environment")).toHaveText("Staging"); + await expect(card.getByTestId("secret-environment")).not.toHaveClass(WARN); + await expect(card).toContainText(HONEST); + await expect(card.getByTestId("secret-input")).toHaveAttribute("type", "password"); + const expiry = card.getByTestId("secret-expiry"); + for (const label of ["No expiry", "1 hour", "24 hours", "7 days"]) await expect(expiry.getByRole("button", { name: label })).toBeVisible(); + await expect(expiry.getByRole("button", { name: "24 hours" })).toHaveAttribute("aria-pressed", "true"); + await expect(card.getByTestId("secret-save")).toBeDisabled(); + // No Allow / Deny for it, and the reply box says where the value goes. + await expect(chat.getByRole("button", { name: /^(Allow|Deny|Yes|No)$/ })).toHaveCount(0); + await expect(pane.locator("[data-testid=composer]").getByRole("textbox", { name: "Reply" })).toHaveAttribute("placeholder", "Give ACME_TOKEN in the card above, never here"); + await card.scrollIntoViewIfNeeded(); + await shot("2-chat-card"); + + step("type the value: masked, and in no attribute of the page while typed"); + const input = card.getByTestId("secret-input"); + await input.fill(SENTINEL); + await expect(card.getByTestId("secret-save")).toBeEnabled(); + expect(await page.evaluate((v) => document.documentElement.outerHTML.includes(v), SENTINEL)).toBe(false); + + step("the reveal toggle shows it, and hides it again"); + const reveal = card.getByTestId("secret-reveal"); + await expect(reveal).toHaveAccessibleName("Show the value"); + await reveal.click(); + await expect(input).toHaveAttribute("type", "text"); + await expect(reveal).toHaveAccessibleName("Hide the value"); + await page.mouse.move(10, 890); + await shot("3-revealed"); + await reveal.click(); + await expect(input).toHaveAttribute("type", "password"); + + step("the box says secret.requested: a needs-you notification names the secret and why, never a value"); + await page.evaluate(() => window.__secrets.announce()); + const toast = page.locator("[data-slot=toast-title]", { hasText: "Speed up product search asks for a secret" }); + await expect(toast).toBeVisible(); + await expect(page.locator("[data-slot=toast-description]", { hasText: "ACME_TOKEN — To call the acme staging API" })).toBeVisible(); + await shot("3b-notification"); + + step("pick 7 days and Save securely: one POST to secrets/requests/sr-acme-token/save"); + await expiry.getByRole("button", { name: "7 days" }).click(); + const before = (await calls())["POST devl/secrets/requests/sr-acme-token/save"] ?? 0; + await card.getByTestId("secret-save").click(); + await expect.poll(async () => (await calls())["POST devl/secrets/requests/sr-acme-token/save"] ?? 0).toBe(before + 1); + await expect.poll(saves).toEqual([{ box: "devl", id: "sr-acme-token", expires: "7d" }]); + + step("the request ends and the card goes; the value is nowhere: no DOM node, no field, no transcript item, no storage"); + await expect(chat.getByTestId("secret-card")).toHaveCount(0); + // secret.request.ended settles the notification: its toast goes. + await expect(toast).toHaveCount(0); + expect(await leftovers(page, SENTINEL)).toEqual([]); + for (const t of await chat.locator("[data-testid=chat-item]").allTextContents()) expect(t.includes(SENTINEL)).toBe(false); + await expect(pane.locator("[data-testid=composer]").getByRole("textbox", { name: "Reply" })).not.toHaveAttribute("placeholder", /ACME_TOKEN/); + await shot("4-saved"); + + step("open gpu/ci-flake's chat: a production replace, environment, project and grant in the warning colour, no expiry picker"); + await open("gpu/ci-flake"); + const prod = chat.getByTestId("secret-card"); + await expect(prod).toHaveAttribute("data-environment", "production"); + await expect(prod.getByTestId("secret-name")).toHaveText("Replace ACME_LEDGER_PROD"); + await expect(prod.getByTestId("secret-replaced")).toHaveText("· Replaces the one saved 9d ago"); + await expect(prod.getByTestId("secret-store")).toHaveText("GCP Secret Manager · acme-prod"); + await expect(prod.getByTestId("secret-where")).toHaveClass(WARN); + await expect(prod.getByTestId("secret-environment")).toHaveText("Production"); + await expect(prod.getByTestId("secret-environment")).toHaveClass(WARN); + await expect(prod.getByTestId("secret-grant")).toHaveText("Grants ledger-backfill@acme-prod.iam.gserviceaccount.com read access to this one secret"); + await expect(prod.getByTestId("secret-grant")).toHaveClass(WARN); + await expect(prod.getByTestId("secret-expiry")).toHaveCount(0); + await expect(prod).toContainText("Production secrets never expire."); + await prod.scrollIntoViewIfNeeded(); + await shot("5-production-card"); + + step("a save the box refuses: its words on the card, the value still in the field and nowhere else, nothing sent again"); + await prod.getByTestId("secret-input").fill(REFUSED); + await prod.getByTestId("secret-save").click(); + await expect(prod.getByTestId("secret-error")).toHaveText("Couldn't save it: Missing permission secretmanager.secrets.create on project acme-prod. The value is still in the field, and nowhere else."); + await expect(prod.getByTestId("secret-input")).toHaveValue(REFUSED); + expect(await page.evaluate((v) => document.documentElement.outerHTML.includes(v), REFUSED)).toBe(false); + expect((await calls())["POST gpu/secrets/requests/sr-acme-ledger/save"]).toBe(1); + await page.waitForTimeout(1500); + expect((await calls())["POST gpu/secrets/requests/sr-acme-ledger/save"]).toBe(1); + expect(await saves()).toHaveLength(1); + await shot("6-save-refused"); + + step("Decline: one POST to secrets/requests/sr-acme-ledger/decline, and the card goes with what was typed"); + await prod.getByTestId("secret-decline").click(); + await expect.poll(async () => (await calls())["POST gpu/secrets/requests/sr-acme-ledger/decline"] ?? 0).toBe(1); + await expect(chat.getByTestId("secret-card")).toHaveCount(0); + expect(await leftovers(page, REFUSED)).toEqual([]); + await shot("7-declined"); + + step("the board again: no secret card left"); + await page.keyboard.press("Meta+j"); + await expect(page.getByTestId("dashboard-board")).toBeVisible(); + await expect(page.getByTestId("dashboard-board").getByTestId("secret-card")).toHaveCount(0); +} diff --git a/.claude/skills/verify-berthd/scripts/scenarios/settings-secrets.mjs b/.claude/skills/verify-berthd/scripts/scenarios/settings-secrets.mjs new file mode 100644 index 000000000..72ce5e0de --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/settings-secrets.mjs @@ -0,0 +1,95 @@ +// Settings › Secrets (features/settings-secrets.md): each box's saved +// secrets by name and facts, never a value; a GCP project the box can't +// list says so; deleting a production secret needs its name typed, a +// staging one only a confirm; where a box saves them is chosen per box and +// per project. +// +// On the mock (devl keeps ACME_STRIPE_TEST in its encrypted file; gpu keeps +// ACME_PROD_DB in acme-prod and ACME_LEDGER_KEY in acme-staging, and can't +// list acme-legacy): +// +// node drive.mjs settings-secrets --port $PORT + +const WARN = /\btext-warning-foreground\b/; + +export async function run({ page, shot, step, expect }) { + step("open Settings › Secrets from the sidebar"); + await page.getByTestId("nav-settings").click(); + await page.getByTestId("settings-nav-secrets").click(); + const body = page.getByTestId("settings-secrets"); + await expect(body).toBeVisible(); + await expect(body.getByRole("heading", { name: "Secrets", level: 1 })).toBeVisible(); + + step("each box lists names and facts: store, environment, expiry, who asked and why"); + const devl = body.getByTestId("secrets-devl"); + const gpu = body.getByTestId("secrets-gpu"); + const row = (box, name) => box.locator(`[data-testid=secret-row][data-name="${name}"]`); + await expect(row(devl, "ACME_STRIPE_TEST")).toContainText("Encrypted file · Expires: in 4d · Saved 3d ago"); + await expect(row(devl, "ACME_STRIPE_TEST")).toContainText("Asked by Fix checkout webhook retries · Claude Code in shop: Replay the webhook fixtures against Stripe's test mode"); + await expect(row(devl, "ACME_STRIPE_TEST").getByTestId("secret-row-environment")).toHaveText("Staging"); + const prod = row(gpu, "ACME_PROD_DB"); + await expect(prod).toContainText("GCP Secret Manager · acme-prod · Expires: never · Saved 12d ago"); + await expect(prod.getByTestId("secret-row-environment")).toHaveText("Production"); + await expect(prod.locator(".text-desc")).toHaveClass(WARN); + await expect(row(gpu, "ACME_LEDGER_KEY")).toContainText("GCP Secret Manager · acme-staging · Expires: in 22h"); + + step("a project the box can't list says so"); + await expect(gpu.getByTestId("secrets-error")).toHaveText("Couldn't list GCP Secret Manager · acme-legacy: missing permission secretmanager.secrets.list on project acme-legacy"); + + step("no value anywhere: no field for one, no Show, only names and facts"); + await expect(body.locator("input[type=password]")).toHaveCount(0); + await expect(body.getByRole("button", { name: /^Show/ })).toHaveCount(0); + await body.evaluate((el) => el.scrollTo(0, 0)); + await shot("1-secrets-list"); + + step("where gpu saves them: its default store and GCP project, and evals' own store"); + await expect(gpu.getByRole("combobox", { name: "Default store on gpu" })).toHaveText("GCP Secret Manager"); + await expect(gpu.getByRole("textbox", { name: "GCP project on gpu" })).toHaveValue("acme-staging"); + await expect(gpu.getByRole("combobox", { name: "Store for evals on gpu" })).toHaveText("Encrypted file"); + await expect(gpu.getByRole("combobox", { name: "Store for shop on gpu" })).toHaveText("Box default"); + step("give shop its own GCP project, and save"); + await gpu.getByRole("combobox", { name: "Store for shop on gpu" }).click(); + await page.getByRole("option", { name: "GCP Secret Manager" }).click(); + await gpu.getByRole("textbox", { name: "GCP project for shop on gpu" }).fill("acme-shop"); + await gpu.getByTestId("secrets-settings-save-gpu").click(); + await expect(page.getByText("Saved where gpu keeps secrets")).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.__berthCalls["PUT gpu/secrets/settings"] ?? 0)).toBe(1); + await expect(gpu.getByTestId("secrets-settings-save-gpu")).toBeDisabled(); + await gpu.getByRole("textbox", { name: "GCP project for shop on gpu" }).scrollIntoViewIfNeeded(); + await shot("2-where-saved"); + + step("delete the production secret: Delete stays disabled until its name is typed exactly"); + await prod.getByTestId("secret-row-delete").click(); + const dialog = page.getByTestId("secret-delete-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("heading", { name: "Delete ACME_PROD_DB?" })).toBeVisible(); + const confirm = dialog.getByTestId("secret-delete-confirm"); + const del = dialog.getByTestId("secret-delete"); + await expect(del).toBeDisabled(); + await confirm.fill("ACME_PROD"); + await expect(del).toBeDisabled(); + await confirm.fill("acme_prod_db"); + await expect(del).toBeDisabled(); + await shot("3-production-delete-locked"); + await confirm.fill("ACME_PROD_DB"); + await expect(del).toBeEnabled(); + await shot("4-production-delete-typed"); + await del.click(); + await expect(dialog).toHaveCount(0); + await expect.poll(() => page.evaluate(() => window.__berthCalls["POST gpu/secrets/delete"] ?? 0)).toBe(1); + await expect(row(gpu, "ACME_PROD_DB")).toHaveCount(0); + + step("a staging secret deletes with a confirm alone"); + await row(devl, "ACME_STRIPE_TEST").getByTestId("secret-row-delete").click(); + await expect(dialog.getByTestId("secret-delete-confirm")).toHaveCount(0); + await expect(dialog.getByTestId("secret-delete")).toBeEnabled(); + await dialog.getByTestId("secret-delete").click(); + await expect(row(devl, "ACME_STRIPE_TEST")).toHaveCount(0); + await expect(devl).toContainText("No secrets yet"); + await body.evaluate((el) => el.scrollTo(0, 0)); + await shot("5-after-deletes"); + + step("Find a setting finds it by what people call it"); + await body.getByRole("textbox", { name: "Find a setting" }).fill("api token"); + await expect(page.getByRole("option", { name: "Secrets" })).toBeVisible(); +} diff --git a/.claude/skills/verify-berthd/scripts/secret-request-live.sh b/.claude/skills/verify-berthd/scripts/secret-request-live.sh new file mode 100755 index 000000000..7ea46396a --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/secret-request-live.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# secret-request-live.sh: on the box dev-box.sh starts, the real Claude in +# session "ask" asks for a secret with `berthd secret request`, the app (or, +# with VERIFY_SAVE=api, the laptop agent's API the app calls) saves a +# sentinel value, and the agent uses it in one command with +# `berthd secret exec --env` and `--file`, checking its sha256. Then every +# place a value must never be is grepped for the sentinel: the agent's +# transcript, the structured host's log, the box's log and state (events, +# turns, inbox, run records, notify), and the laptop agent's state with its +# offline queue. Each must have 0 hits. +# +# dev-box.sh up # terminal Claude (or VERIFY_DRIVER=claude: structured) +# PORT=1433 secret-request-live.sh # saves through the app on vite preview :1433 +# VERIFY_SAVE=api secret-request-live.sh # saves through the agent's API, no browser +# dev-box.sh down +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +root=$(git -C "$here" rev-parse --show-toplevel) +dir=${VERIFY_BOX_DIR:-/tmp/verify-berthd-box} +eval "$("$here/dev-box.sh" env 2>/dev/null)" +api=http://127.0.0.1:9378/v1/boxes/devq/api +out=${VERIFY_OUT:-/tmp/verify-berthd/secret-request-live-$(date +%Y%m%d-%H%M%S)} +mkdir -p "$out" +call() { curl -sS -H "Authorization: Bearer $VERIFY_BERTH_TOKEN" -H "Content-Type: application/json" "$@"; } +fail() { echo "FAIL: $*" >&2; exit 1; } +json() { python3 -c "import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1]))" "$1"; } + +# A fresh sentinel each run, and the hash the agent compares with: the +# agent is only ever given the hash. +sentinel="acme-sentinel-$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')" +hash=$(printf %s "$sentinel" | sha256sum | cut -d' ' -f1) +name=ACME_TOKEN +berthd=$root/bin/berthd +marks=$(mktemp -d /tmp/secret-live-XXXX) + +send() { call -X POST "$api/sessions/ask/send" -d "$(python3 -c 'import json,sys; print(json.dumps({"text": sys.argv[1], "when": "idle"}))' "$1")" >/dev/null; } + +echo "1. the agent asks for $name" +# An earlier run's secret would come back at once, with no card. +call "$api/secrets" | python3 -c " +import json,sys +for s in json.load(sys.stdin).get('secrets', []): + if s['name'] == '$name': print(s['ref'])" | while read -r ref; do + call -X POST "$api/secrets/delete" -d "{\"ref\":\"$ref\",\"confirm\":\"$name\"}" >/dev/null +done +send "Run exactly this shell command, then end your turn without running anything else: $berthd secret request $name --reason 'verify: deploy the acme app to staging' --ttl 24h" +id="" +for _ in $(seq 120); do + id=$(call "$api/secrets/requests" | python3 -c " +import json,sys +for r in json.load(sys.stdin): + if r['session'] == 'ask' and r['name'] == '$name': print(r['id'])" | head -1) + [[ -n $id ]] && break + sleep 1 +done +[[ -n $id ]] || fail "no request from the agent after 2 minutes" +echo " request $id" +state=$(call "$api/sessions" | python3 -c " +import json,sys +s=[x for x in json.load(sys.stdin) if x['name']=='ask'][0] +print(s.get('agent_state'), (s.get('secret_request') or {}).get('id'))") +[[ $state == "waiting $id" ]] || fail "the session doesn't read as waiting on the request: $state" + +echo "2. the person saves it" +if [[ ${VERIFY_SAVE:-app} == api ]]; then + res=$(call -X POST "$api/secrets/requests/$id/save" -d "{\"value\":\"$sentinel\",\"expires\":\"\"}") + [[ $(json 'd["ref"]' <<<"$res") == "box://$name" ]] || fail "save: $res" +else + [[ -n ${PORT:-} ]] || fail "PORT (the app's vite preview) is needed to save through the app; or VERIFY_SAVE=api" + VERIFY_SECRET_SENTINEL=$sentinel node "$here/drive.mjs" secret-request-live --port "$PORT" --agent http://127.0.0.1:9378 --out "$out/app" +fi +[[ $(call "$api/secrets/requests/$id" | json 'd["status"]') == saved ]] || fail "the request isn't saved" + +echo "3. the agent hears back, and uses it in one command" +# The notification is a turn of its own; the next prompt waits behind it. +send "Run exactly these two shell commands, then end your turn. Do not print the secret. +$berthd secret exec --env T=box://$name -- sh -c '[ \"\$(printf %s \"\$T\" | sha256sum | cut -d\" \" -f1)\" = $hash ] && echo ok > $marks/env' +$berthd secret exec --file F=box://$name -- sh -c '[ \"\$(sha256sum < \"\$F\" | cut -d\" \" -f1)\" = $hash ] && echo \"\$F\" > $marks/file'" +for _ in $(seq 240); do + [[ -s $marks/env && -s $marks/file ]] && break + sleep 1 +done +[[ -s $marks/env ]] || fail "secret exec --env: the hash didn't match, or it never ran" +[[ -s $marks/file ]] || fail "secret exec --file: the hash didn't match, or it never ran" +tmpfile=$(cat "$marks/file") +[[ ! -e $tmpfile ]] || fail "--file left $tmpfile behind" +echo " --env and --file both matched; $tmpfile is gone" +sleep 5 + +echo "4. the sentinel is nowhere it must not be" +# The agent's own transcript: Claude keeps it under ~/.claude/projects by +# folder; the structured host's log and every state file of the box and +# the laptop agent are under $dir, the encrypted store too (sealed, so it +# holds no plaintext either). +proj=$HOME/.claude/projects/$(sed 's#[/.]#-#g' <<<"$dir/acme-shop") +hits=0 +for where in "$dir" "$proj"; do + [[ -e $where ]] || continue + if found=$(grep -rlF "$sentinel" "$where" 2>/dev/null); then + echo " HIT in: $found" >&2 + hits=$((hits + $(wc -l <<<"$found"))) + fi +done +screen=$(env TMUX_TMPDIR="$dir/tmux" tmux -L berth capture-pane -p -S - -t ask 2>/dev/null || true) +if grep -qF "$sentinel" <<<"$screen"; then echo " HIT on the agent's screen" >&2; hits=$((hits + 1)); fi +echo " grepped: $dir (box log, box and laptop state: events, turns.json, inbox.json, runs, notify.json, queue.json), $proj, the agent's screen" +[[ $hits == 0 ]] || fail "$hits places hold the sentinel" +call "$api/secrets" >"$out/secrets.json" +grep -qF "$sentinel" "$out/secrets.json" && fail "GET secrets holds the value" +rm -rf "$marks" +echo "OK: requested, saved, used by --env and --file, 0 hits for the sentinel. Evidence: $out" diff --git a/app/e2e/a11y-scenes.ts b/app/e2e/a11y-scenes.ts index d8984622c..a703f993f 100644 --- a/app/e2e/a11y-scenes.ts +++ b/app/e2e/a11y-scenes.ts @@ -174,6 +174,14 @@ export const scenes: Scene[] = [ await chat(app, theme, "gpu/ci-flake"); }, }, + { + // An agent asking for a production secret (?secrets, lib/mock-secrets). + id: "chat-secret-request", + async run(app, theme) { + await chat(app, theme, "gpu/ci-flake", { secrets: "1" }); + await expect(app.chat.getByTestId("secret-card")).toBeVisible(); + }, + }, { id: "terminal", async run(app, theme) { @@ -317,7 +325,7 @@ export const scenes: Scene[] = [ await expect(page.getByTestId("install-plan")).toBeVisible(); }, }, - ...["general", "notifications", "appearance", "terminal", "boxes", "computers", "phone", "agents", "integrations", "plugins", "shortcuts", "labs", "about", "developer"].map(settings), + ...["general", "notifications", "appearance", "terminal", "boxes", "computers", "phone", "agents", "integrations", "secrets", "plugins", "shortcuts", "labs", "about", "developer"].map(settings), { id: "sidebar-rename", async run(app, theme) { diff --git a/app/src/components/command-palette.tsx b/app/src/components/command-palette.tsx index ba338c7cb..cf868c238 100644 --- a/app/src/components/command-palette.tsx +++ b/app/src/components/command-palette.tsx @@ -99,6 +99,7 @@ const SETTINGS_SECTIONS: [SettingsSectionId, string, string][] = [ ["phone", "Phone", "mobile pair"], ["agents", "Agents", "claude codex install"], ["integrations", "Integrations", "linear api key issues"], + ["secrets", "Secrets", "tokens keys credentials secret manager"], ["plugins", "Plugins", "extensions"], ["shortcuts", "Shortcuts", "keys keyboard"], ["labs", "Labs", "experiments"], diff --git a/app/src/components/conversation/conversation-pane.tsx b/app/src/components/conversation/conversation-pane.tsx index 5ab30b90d..27d2d0c11 100644 --- a/app/src/components/conversation/conversation-pane.tsx +++ b/app/src/components/conversation/conversation-pane.tsx @@ -16,6 +16,7 @@ import { PixelLoader } from "@/components/pixel-loader"; import { useComposerMenu } from "@/components/conversation/command-menu"; import { LiveScreen, useLiveScreen } from "@/components/conversation/live-screen"; import { QuestionsContext } from "@/components/conversation/question-form"; +import { SecretRequestCard } from "@/components/conversation/secret-request-card"; import { ResumePicker } from "@/components/conversation/resume-picker"; import { toastError } from "@/components/error-note"; import { UpgradeBox } from "@/components/upgrade-box"; @@ -45,6 +46,7 @@ import { addComment, type LineComment, pending, removeComment, sendComments, use import { githubRepo } from "@/lib/repo-refs"; import { answerQuestions, atMenu, lastQuestion, type QuestionAnswer } from "@/lib/questions"; import { permissionChoices } from "@/lib/screen"; +import { SECRET_TOOL, secretRequestOf } from "@/lib/secret-request"; import { NONE, useStore } from "@/lib/store"; import { isStructured } from "@/lib/structured"; import type { TranscriptItem } from "@/lib/transcript"; @@ -95,7 +97,10 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS // Turn check's wait asks in the agent's last message, which the reply // box answers: its screen, at its prompt, holds no question to read. const asking = atQuestion(s, state); - const ask = useAsk(box, session, !mock && asking, s?.state_since, s?.ask); + // A secret it asked for (berthd secret request): its own card, at the + // chat's foot, answers it; never Allow or Deny, never the reply box. + const secret = state === "exited" ? undefined : secretRequestOf(s); + const ask = useAsk(box, session, !mock && asking && !secret, s?.state_since, s?.ask); const [answered, setAnswered] = useState<{ at?: string; key: string }>(); // A form of questions the agent asks (Claude Code's AskUserQuestion), // from its record: the chat draws it whole and the box fills it in. stuck @@ -224,7 +229,8 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS const decided = answered && answered.at === s?.state_since ? answered.key : undefined; // A form of questions draws itself (its question item); one the chat // can't read from the record (an older box) only its screen answers. - const form = formAsk || !!ask?.form; + // A secret it asked for has its own card in the chat's tail, not an item. + const form = formAsk || !!ask?.form || !!secret || s?.ask?.tool === SECRET_TOOL; if (state === "waiting" && s?.ask?.tool && !form && !formDone) { // The agent's hooks said what it asks: show that, with its screen's // options matched to Allow, Always allow and Deny. @@ -237,7 +243,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS out.push({ kind: "ask", id: "live:ask", tool: "Question", detail: s?.ask?.message || ask.detail, choices: ask.choices, decided }); } return out; - }, [mock, items, state, parked, s?.state_since, s?.ask, s?.created, startedWith, ask, answered, sent, prompted, onScreen, last, formAsk, formDone, structured, drafts.draft, drafts.status, drafts.supported]); + }, [mock, items, state, parked, s?.state_since, s?.ask, s?.created, startedWith, ask, answered, sent, prompted, onScreen, last, formAsk, formDone, structured, drafts.draft, drafts.status, drafts.supported, secret]); const edits = useMemo(() => { if (!client || (!canDiff && !mock)) return undefined; @@ -284,7 +290,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS return () => window.clearTimeout(t); }, [answered, s?.state_since, state, forWords]); const staleNow = !!stale && stale === s?.state_since; - const recognised = state === "waiting" && !staleNow && (answerable || (!formAsk && !ask?.form && (ask ? !!ask.choices.length : !!s?.ask?.tool))); + const recognised = state === "waiting" && !staleNow && (!!secret || answerable || (!formAsk && !ask?.form && (ask ? !!ask.choices.length : !!s?.ask?.tool))); const live = useLiveScreen({ box, session, agent, enabled: visible && !mock && !away && !!s && state !== "exited" && !recognised && !structured, running: state === "running", nudge }); // Claude Code and Codex write their conversation once they start: until @@ -477,6 +483,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS const untaken = !mock && !structured && !!onShowTerminal && state !== "running" && sent.some((p, i) => now - p.at > 8000 && !taken(items, p, i + 1)); const tail = ( <> + {secret && } {queue.items.map((q) => ( sendNow(q)} onCancel={() => cancel(q)} /> ))} @@ -595,7 +602,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS - + @@ -619,8 +626,8 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS live={live.show} terminal={!!onShowTerminal} mode={replyMode(state)} - blocked={(asking && menu) || live.show} - hint={live.show ? (liveWhy ?? `${who} is showing its own screen: answer it above`) : wantsWords ? `Tell ${who} what to change, then press Enter` : answerable ? `Answer ${who}'s ${openQ?.questions.length === 1 ? "question" : "questions"} above` : undefined} + blocked={(asking && menu && !secret) || live.show} + hint={live.show ? (liveWhy ?? `${who} is showing its own screen: answer it above`) : secret ? `Give ${secret.name} in the card above, never here` : wantsWords ? `Tell ${who} what to change, then press Enter` : answerable ? `Answer ${who}'s ${openQ?.questions.length === 1 ? "question" : "questions"} above` : undefined} actions={actions} /> diff --git a/app/src/components/conversation/report-card.tsx b/app/src/components/conversation/report-card.tsx index 654976e91..a62be283b 100644 --- a/app/src/components/conversation/report-card.tsx +++ b/app/src/components/conversation/report-card.tsx @@ -29,6 +29,10 @@ const WORDS: Record = { waiting_gate: ["waits at a gate", "needs"], cancelled: ["was cancelled", "ended"], interrupted: ["was interrupted", "ended"], + // A secret the agent asked for (berthd secret request). + saved: ["saved", "done"], + declined: ["declined", "ended"], + timed_out: ["timed out", "ended"], }; const cap = (w: string) => w.charAt(0).toUpperCase() + w.slice(1); @@ -37,6 +41,7 @@ const cap = (w: string) => w.charAt(0).toUpperCase() + w.slice(1); // else the session, else the run. export function reportName(r: BerthReport): string { if (r.kind === "run") return r.title || r.template || r.run || "A run"; + if (r.kind === "secret") return r.title || "A secret"; const wt = r.worktree?.split("/").pop(); return wt || r.session || "An agent"; } @@ -87,8 +92,8 @@ export function ReportCard({ it }: { it: Extract} name={name} - kind={r.kind === "run" ? "Run" : "Agent"} - chip={r.status === "waiting_gate" ? { word: "At a gate", Icon: MilestoneIcon, tone: "ask" } : { ...CHIP[tone], word: r.kind === "run" || tone !== "done" ? cap(word) : CHIP[tone].word }} + kind={r.kind === "run" ? "Run" : r.kind === "secret" ? "Secret" : "Agent"} + chip={r.status === "waiting_gate" ? { word: "At a gate", Icon: MilestoneIcon, tone: "ask" } : { ...CHIP[tone], word: r.kind === "run" || r.kind === "secret" || tone !== "done" ? cap(word) : CHIP[tone].word }} extra={ changed && ( <> @@ -100,7 +105,7 @@ export function ReportCard({ it }: { it: Extract s.client); + const sessions = useStore((s) => s.boxes[box]?.sessions); + const asker = sessions?.find((s) => s.name === request.session); + const who = asker ? sessionName(asker, { sessions, agent: true }) : request.session; + const [state, dispatch] = useReducer(cardReducer, IDLE); + const field = useRef(null); + // How much is typed, for Save: its length, never the value. + const [typed, setTyped] = useState(0); + const [revealed, setRevealed] = useState(false); + const [expiry, setExpiry] = useState(() => initialExpiry(request)); + const prod = isProduction(request); + const options = expiryOptions(request); + const grant = grantLine(request); + const replaced = replacedLine(request); + const warn = prod && "text-warning-foreground"; + const inFlight = state.phase === "saving" || state.phase === "declining"; + // A refusal shows above the buttons: both brought into view, as the + // chat doesn't move for it. + const said = useRef(null); + useEffect(() => { + if (state.phase === "error") said.current?.scrollIntoView({ block: "nearest" }); + }, [state]); + + const save = async () => { + const el = field.current; + if (!client || !el || !canSave(state, el.value.length)) return; + dispatch({ type: "save" }); + try { + const { ref } = await boxApi.saveSecret(client, box, request.id, el.value, sentExpiry(request, expiry)); + el.value = ""; + setTyped(0); + setRevealed(false); + dispatch({ type: "saved", ref }); + } catch (err) { + dispatch({ type: "failed", message: plainError(err, { box }) }); + } + }; + const decline = async () => { + if (!client || !canDecline(state)) return; + dispatch({ type: "decline" }); + try { + await boxApi.declineSecret(client, box, request.id); + if (field.current) field.current.value = ""; + setTyped(0); + dispatch({ type: "declined" }); + } catch (err) { + dispatch({ type: "failed", message: plainError(err, { box }) }); + } + }; + + // Over: the box drops the request, and this line goes with it a moment + // later. Never the field again. + if (state.phase === "saved" || state.phase === "declined") { + const saved = state.phase === "saved"; + return ( +
+ {saved ? : } + {saved ? "Saved" : "Declined"} + {request.name} + {saved && · {who} gets {state.ref}} +
+ ); + } + + const error = errorLine(state, box); + const body = ( + <> +
+
+ +
+
+ Secret request: + + {cardTitle(request)} + +
+ {/* On the board, the card around it names who asks. */} + {(!compact || replaced) && ( +
+ {!compact && ( + <> + Asked by {who} + + )} + {replaced && ( + + {compact ? "" : " · "} + {replaced} + + )} +
+ )} +
+
+ {/* The agent's words as it wrote them. */} +

+ {request.reason} +

+
+
Saved in
+
+ {request.store_label || request.store} + {request.where && ( + <> + {" · "} + + {request.where} + + + )} +
+ {request.environment && ( + <> +
Environment
+
+ {prod ? "Production" : request.environment === "staging" ? "Staging" : request.environment} +
+ + )} + {grant && ( + <> +
Access
+
+ {grant} +
+ + )} +
+
+ setTyped(e.currentTarget.value.length)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + void save(); + } + }} + className="min-w-0 flex-1 font-mono text-mono [&_input::placeholder]:font-sans" + /> + + + +
+ {options ? ( +
+ Expires + {/* The board's card is narrow: the same four, in short. */} + label={`When ${request.name} expires`} value={expiry} options={compact ? options.map((o) => ({ value: o.value, label: o.value || "Never" })) : options} onChange={setExpiry} /> +
+ ) : ( +

Production secrets never expire. Delete it in Settings › Secrets.

+ )} +

{HONEST_LINE}

+ {error && ( + + )} +
+
+ + + {!compact && The agent waits for you} +
+ + ); + + if (compact) { + // Inside the board's card, whose click opens the agent: here, clicks + // and keys are the request's. + return ( +
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> + {body} +
+ ); + } + return ( + + {body} + + ); +} diff --git a/app/src/components/sidebar/rail.tsx b/app/src/components/sidebar/rail.tsx index 2289a3e1a..4253b688d 100644 --- a/app/src/components/sidebar/rail.tsx +++ b/app/src/components/sidebar/rail.tsx @@ -10,6 +10,7 @@ import { useTones } from "@/components/workspace/worktree-tone"; import type { Location, Session, Worktree } from "@/lib/api"; import { agentLabel, agentOf, ON_ITS_WORK, onItsWork, type SessionState, sessionState } from "@/lib/derive"; import { ago } from "@/lib/format"; +import { SECRET_TOOL } from "@/lib/secret-request"; import { usePrefs } from "@/lib/prefs"; import { useProjects } from "@/lib/project-groups"; import { load, save } from "@/lib/storage"; @@ -151,7 +152,7 @@ export function AgentCard({ e }: { e: RailAgent }) { {e.state === "running" && !e.away && onItsWork(e.session) && {ON_ITS_WORK}} {ask && (ask.input || ask.message) && ( - {ask.tool ? `${ask.tool}: ` : ""} + {ask.tool && ask.tool !== SECRET_TOOL ? `${ask.tool}: ` : ""} {ask.input ?? ask.message} )} diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 8a4b74241..ca0dec0a3 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -339,8 +339,90 @@ export const boxApi = { // testSecret asks the box to resolve a secret reference now. It reports // whether it could and the value's length, never the value. testSecret: (c: Client, box: string, ref: string) => c.box(box, "POST", "secrets/test", { ref }), + // The secrets agents asked for and wait on (berthd secret request). + secretRequests: async (c: Client, box: string) => (await c.box(box, "GET", "secrets/requests")) ?? [], + // saveSecret hands the person's value to the box in this one request, + // which stores it and answers with its reference: the value is never + // kept, queued or sent again from here (lib/secret-request.ts). + saveSecret: (c: Client, box: string, id: string, value: string, expires: SecretExpiry) => + c.box<{ ref: string }>(box, "POST", `secrets/requests/${encodeURIComponent(id)}/save`, { value, expires }), + declineSecret: (c: Client, box: string, id: string) => c.box<{ ok: true }>(box, "POST", `secrets/requests/${encodeURIComponent(id)}/decline`), + // The secrets saved through requests: names and where they are, never a + // value. + secrets: (c: Client, box: string) => c.box(box, "GET", "secrets"), + // A production secret is deleted only with its name typed as confirm. + deleteSecret: (c: Client, box: string, ref: string, confirm?: string) => c.box<{ ok: true }>(box, "POST", "secrets/delete", confirm === undefined ? { ref } : { ref, confirm }), + secretSettings: (c: Client, box: string) => c.box(box, "GET", "secrets/settings"), + putSecretSettings: (c: Client, box: string, s: SecretSettings) => c.box(box, "PUT", "secrets/settings", s), }; +// How long a saved secret lasts: "" never expires. +export type SecretExpiry = "" | "1h" | "24h" | "7d"; + +// A secret an agent asked the person for (berthd secret request NAME +// --reason …), pending on its session (Session.secret_request) until saved, +// declined or an hour passes. It names the secret and where it will go; +// the value goes only from the card to the box (boxApi.saveSecret). +export interface SecretRequest { + id: string; + name: string; + reason: string; + session: string; + location?: string; + // The provider ("local", "gcp") and its words ("Encrypted file", "GCP + // Secret Manager"); where is its place, a GCP project. + store: string; + store_label: string; + where?: string; + environment?: "staging" | "production"; + // A service account the save grants read access to this one secret. + grant?: string; + // The expiry the agent suggests. + ttl?: Exclude; + // A secret of this name is saved already, since replaced_at. + replace?: boolean; + replaced_at?: string; + created: string; + expires: string; + status: "pending"; +} + +// A saved secret as Settings › Secrets lists it: never its value. +export interface SecretMeta { + name: string; + ref: string; + store: string; + store_label: string; + where?: string; + environment?: "staging" | "production"; + created: string; + expires?: string; + session?: string; + reason?: string; + location?: string; +} + +export interface SecretStore { + id: string; + label: string; + caps: { expiry: boolean; labels: boolean; grant: boolean }; +} + +// Where a box saves secrets: its default store, and a project's own. +export interface SecretSettings { + store: string; + gcp_project?: string; + locations?: Record; +} + +export interface SecretsList { + secrets: SecretMeta[] | null; + stores: SecretStore[] | null; + settings: SecretSettings; + // A store the box couldn't list (a GCP project it has no access to). + errors?: { store: string; where?: string; error: string }[]; +} + export interface SessionDiff { file: string; diff: string; diff --git a/app/src/lib/events.ts b/app/src/lib/events.ts index 7f87e7ab0..087bf5d42 100644 --- a/app/src/lib/events.ts +++ b/app/src/lib/events.ts @@ -5,7 +5,8 @@ import { handleBrowserOpened } from "@/lib/agent-browser-open"; import { handleArtifactEvent } from "@/lib/art/model"; import { agentLabel, agentOf, sessionAgent, sessionName } from "@/lib/derive"; import { isLive, useLoops } from "@/lib/loops"; -import { flowKey, resolveFromEvent, route, secretKey, serviceKey } from "@/lib/notifications"; +import { flowKey, resolveFromEvent, route, secretKey, secretRequestKey, serviceKey } from "@/lib/notifications"; +import { askLine, SECRET_TOOL } from "@/lib/secret-request"; import { handlePreview } from "@/lib/preview"; import { handleQueueEvent } from "@/lib/queue"; import { scheduleRuns } from "@/lib/runs"; @@ -33,6 +34,8 @@ const refreshes: [prefix: string, parts: BoxPart[]][] = [ ["review.", ["locations"]], // Agent CLIs added or found on the box: the agents it offers. ["agents.", ["info"]], + // A secret asked for, saved, declined or timed out: the session's card. + ["secret.", ["sessions"]], ]; // The most recent events, newest first, for the Automations view. @@ -85,8 +88,10 @@ function notifyFor(e: BerthEvent) { // agent finishing is not news. const sessions = box ? useStore.getState().boxes[box]?.sessions : undefined; const looped = !waiting && useLoops.getState().loops.some((l) => isLive(l) && l.box === box && (l.session === where.session || (!!where.path && sessions?.find((s) => s.name === l.session)?.dir === where.path))); + // Waiting on a secret it asked for: secret.requested says that, better. + const forSecret = waiting && (d.tool === SECRET_TOOL || sessions?.find((s) => s.name === where.session)?.ask?.tool === SECRET_TOOL); // Stopped from the chat (Esc, Stop): the person knows; it isn't done. - if (!looped && d.source !== "interrupt") { + if (!looped && !forSecret && d.source !== "interrupt") { route({ category: waiting ? "waiting" : "finished", title: waiting ? `${where.agent} needs you` : `${where.agent} is done`, @@ -207,6 +212,29 @@ function notifyFor(e: BerthEvent) { key: secretKey(box, d.location, d.variable), }); } + // An agent asks for a secret: it waits on the person as at a question. + // The event names the secret and why, never a value; the card in its + // chat (or on the board) takes it. Settled by secret.request.ended. + if (e.type === "secret.requested" && box) { + // The event names the session, not its folder: the folder is its. + const dir = useStore.getState().boxes[box]?.sessions?.find((s) => s.name === d.session)?.dir; + const where = describeAgent({ ...e, data: { ...d, path: d.path ?? dir } }); + const session = str(d.session) ?? where.session; + const name = str(d.name) ?? "a secret"; + route({ + category: "waiting", + title: `${where.agent} asks for a secret`, + detail: askLine({ name, reason: str(d.reason) }), + tone: "warning", + box, + path: where.path, + project: where.project ?? str(d.location), + worktree: where.worktree, + session, + action: session ? { kind: "session", box, session } : str(d.location) ? { kind: "project", box, location: str(d.location)! } : undefined, + key: secretRequestKey(box, d.id), + }); + } // A flow's notify step: its own title and body, from whichever box. if (e.type === "notify") { const path = str(d.path); diff --git a/app/src/lib/mock-secrets.ts b/app/src/lib/mock-secrets.ts new file mode 100644 index 000000000..f85c5c997 --- /dev/null +++ b/app/src/lib/mock-secrets.ts @@ -0,0 +1,160 @@ +import { ApiError, type BerthEvent, type SecretExpiry, type SecretMeta, type SecretRequest, type SecretSettings, type SecretStore, type Session } from "@/lib/api"; + +// Secrets in demo mode (?mock=1): what boxes keep (Settings › Secrets) and, +// with ?secrets, two agents waiting on secrets they asked for (berthd +// secret request): devl's search-perf Claude wants a staging token in the +// box's encrypted file, gpu's ci-flake Claude a production one in GCP +// Secret Manager, with a service account granted read access. +// +// A save is answered as a box would, with a reference; the value is never +// kept. window.__secretSaves records that a save happened (box, request, +// expiry), for tests, and never the value or its length. A value with +// "denied" in it is refused as GCP would refuse a missing permission, so +// the card's error shows. + +type Ctx = { sessions: Record; emit: (e: Omit) => void; delay: (v: T) => Promise }; + +const minutes = (n: number) => new Date(Date.now() + n * 60_000).toISOString(); + +const LOCAL: SecretStore = { id: "local", label: "Encrypted file", caps: { expiry: true, labels: false, grant: false } }; +const GCP: SecretStore = { id: "gcp", label: "GCP Secret Manager", caps: { expiry: true, labels: true, grant: true } }; + +const stores: Record = { devl: [LOCAL, GCP], gpu: [LOCAL, GCP] }; + +const settings: Record = { + devl: { store: "local" }, + gpu: { store: "gcp", gcp_project: "acme-staging", locations: { evals: { store: "local" } } }, +}; + +const saved: Record = { + devl: [ + { name: "ACME_STRIPE_TEST", ref: "box://ACME_STRIPE_TEST", store: "local", store_label: "Encrypted file", environment: "staging", created: minutes(-3 * 24 * 60), expires: minutes(4 * 24 * 60), session: "checkout-fix-claude", location: "shop", reason: "Replay the webhook fixtures against Stripe's test mode" }, + ], + gpu: [ + { name: "ACME_PROD_DB", ref: "gcp://projects/acme-prod/secrets/ACME_PROD_DB", store: "gcp", store_label: "GCP Secret Manager", where: "acme-prod", environment: "production", created: minutes(-12 * 24 * 60), session: "ci-flake-claude", location: "shop", reason: "Read the ledger's row counts in production" }, + { name: "ACME_LEDGER_KEY", ref: "gcp://projects/acme-staging/secrets/ACME_LEDGER_KEY", store: "gcp", store_label: "GCP Secret Manager", where: "acme-staging", environment: "staging", created: minutes(-26 * 60), expires: minutes(22 * 60), session: "judge-v2-claude", location: "evals", reason: "Score the judge against the staging ledger" }, + ], +}; + +// A project the box can't list: Settings says so instead of hiding it. +const listErrors: Record = { + gpu: [{ store: "gcp", where: "acme-legacy", error: "missing permission secretmanager.secrets.list on project acme-legacy" }], +}; + +// seedSecretRequests puts the two pending requests on their sessions +// (?secrets). +export function seedSecretRequests(sessions: Record) { + const put = (box: string, name: string, r: Omit) => { + const s = sessions[box]?.find((x) => x.name === name); + if (!s) return; + Object.assign(s, { + agent_state: "waiting", + soft_wait: true, + state_since: minutes(-1), + ask: { tool: "SecretRequest", message: `Asks for the secret ${r.name}` }, + secret_request: { ...r, session: name, created: minutes(-1), expires: minutes(59), status: "pending" }, + }); + }; + put("devl", "search-perf-claude", { + id: "sr-acme-token", + name: "ACME_TOKEN", + reason: "To call the acme staging API from the search benchmark.\nRead-only is enough.", + location: "shop", + store: "local", + store_label: "Encrypted file", + environment: "staging", + ttl: "24h", + }); + put("gpu", "ci-flake-claude", { + id: "sr-acme-ledger", + name: "ACME_LEDGER_PROD", + reason: "Backfill the new ledger from this year's production invoices", + location: "shop", + store: "gcp", + store_label: "GCP Secret Manager", + where: "acme-prod", + environment: "production", + grant: "ledger-backfill@acme-prod.iam.gserviceaccount.com", + ttl: "1h", + replace: true, + replaced_at: minutes(-9 * 24 * 60), + }); +} + +// window.__secrets.announce() plays each pending request's +// secret.requested, as a box sends it when the agent asks, for the +// notification it routes. +export function wireSecretsMock(sessions: Record, emit: Ctx["emit"]) { + (window as unknown as { __secrets: unknown }).__secrets = { + announce: () => { + for (const [box, list] of Object.entries(sessions)) for (const { r } of pending(list)) emit({ type: "secret.requested", box, data: { id: r.id, session: r.session, name: r.name, location: r.location, reason: r.reason } }); + }, + }; +} + +const pending = (sessions: Session[] | undefined) => + (sessions ?? []).flatMap((s) => { + const r = (s as Session & { secret_request?: SecretRequest }).secret_request; + return r ? [{ s, r }] : []; + }); + +// endRequest takes a request off its session, which goes back to work. +function endRequest(ctx: Ctx, box: string, s: Session, r: SecretRequest, status: "saved" | "declined") { + delete (s as Session & { secret_request?: SecretRequest }).secret_request; + Object.assign(s, { agent_state: "running", soft_wait: undefined, ask: undefined, state_since: new Date().toISOString() }); + ctx.emit({ type: "secret.request.ended", box, data: { id: r.id, session: s.name, name: r.name, status } }); +} + +export function secretsCall(box: string, method: string, path: string, body: unknown, ctx: Ctx): Promise | undefined { + if (path !== "secrets" && !path.startsWith("secrets/") && !path.startsWith("secrets?")) return undefined; + if (path === "secrets/test") return undefined; + const { delay } = ctx; + if (method === "GET" && path === "secrets/requests") return delay(pending(ctx.sessions[box]).map((p) => p.r)); + const act = method === "POST" ? /^secrets\/requests\/([^/]+)\/(save|decline)$/.exec(path) : null; + if (act) { + const hit = pending(ctx.sessions[box]).find((p) => p.r.id === decodeURIComponent(act[1])); + if (!hit) return Promise.reject(new ApiError("no pending secret request with that id; it may have timed out", 404, "not_found")); + const { s, r } = hit; + if (act[2] === "decline") { + endRequest(ctx, box, s, r, "declined"); + return delay({ ok: true }); + } + const req = (body ?? {}) as { value?: string; expires?: SecretExpiry }; + if (!req.value) return Promise.reject(new ApiError("a secret needs a value", 400)); + if (req.value.includes("denied")) return new Promise((_, reject) => setTimeout(() => reject(new ApiError(`missing permission secretmanager.secrets.create on project ${r.where ?? "acme-staging"}`, 403)), 300)); + const expires = r.environment === "production" ? "" : (req.expires ?? ""); + const w = window as unknown as { __secretSaves?: { box: string; id: string; expires: string }[] }; + (w.__secretSaves ??= []).push({ box, id: r.id, expires }); + const ref = r.store === "gcp" ? `gcp://projects/${r.where}/secrets/${r.name}` : `box://${r.name}`; + const hours = { "": 0, "1h": 1, "24h": 24, "7d": 168 }[expires]; + const list = (saved[box] ??= []).filter((m) => m.name !== r.name || m.store !== r.store); + list.unshift({ name: r.name, ref, store: r.store, store_label: r.store_label, where: r.where, environment: r.environment, created: new Date().toISOString(), expires: hours ? minutes(hours * 60) : undefined, session: s.name, location: r.location, reason: r.reason }); + saved[box] = list; + // As a box answers: after a moment, the request ends and the agent goes on. + return new Promise((resolve) => + setTimeout(() => { + endRequest(ctx, box, s, r, "saved"); + ctx.emit({ type: "secret.saved", box, data: { name: r.name, store: r.store } }); + resolve({ ref }); + }, 300), + ); + } + if (method === "GET" && path === "secrets") return delay({ secrets: saved[box] ?? [], stores: stores[box] ?? [LOCAL], settings: settings[box] ?? { store: "local" }, errors: listErrors[box] }); + if (method === "POST" && path === "secrets/delete") { + const { ref, confirm } = (body ?? {}) as { ref?: string; confirm?: string }; + const m = (saved[box] ?? []).find((x) => x.ref === ref); + if (!m) return Promise.reject(new ApiError("no secret with that reference", 404, "not_found")); + if (m.environment === "production" && confirm !== m.name) return Promise.reject(new ApiError(`type ${m.name} to delete a production secret`, 400)); + saved[box] = (saved[box] ?? []).filter((x) => x !== m); + ctx.emit({ type: "secret.deleted", box, data: { name: m.name, store: m.store } }); + return delay({ ok: true }); + } + if (method === "GET" && path === "secrets/settings") return delay(settings[box] ?? { store: "local" }); + if (method === "PUT" && path === "secrets/settings") { + const next = body as SecretSettings; + if (!(stores[box] ?? [LOCAL]).some((s) => s.id === next.store)) return Promise.reject(new ApiError(`no store "${next.store}" on ${box}`, 400)); + settings[box] = next; + return delay(next); + } + return undefined; +} diff --git a/app/src/lib/mock.ts b/app/src/lib/mock.ts index 729b3412a..d74b45bb9 100644 --- a/app/src/lib/mock.ts +++ b/app/src/lib/mock.ts @@ -8,6 +8,7 @@ import { browserCall, mockScreencast, mockShotSvg, wireBrowserMock } from "@/lib import { mockDevtoolsCall } from "@/lib/mock-devtools"; import { phoneCall } from "@/lib/mock-phone"; import { turnCheckCall } from "@/lib/mock-turncheck"; +import { secretsCall, seedSecretRequests, wireSecretsMock } from "@/lib/mock-secrets"; import { worktreesCall } from "@/lib/mock-worktrees"; import { kitsCall, kitsStream } from "@/lib/mock-kits"; import { marketplaceCall, marketplaceFile, marketplaceReady, type MockPlugin, seededPlugins } from "@/lib/mock-marketplace"; @@ -255,6 +256,9 @@ if (structuredDemo) { }, } satisfies Partial); } +// ?secrets: devl's search-perf Claude and gpu's ci-flake Claude wait on +// secrets they asked for, a staging and a production one (lib/mock-secrets.ts). +if (new URLSearchParams(location.search).has("secrets")) seedSecretRequests(sessions); // ?mock=1&bench=…: far more than the fixtures, for measuring (lib/mock-bench.ts). if (BENCH === "fleet") benchFleet({ status, locations, sessions, stats }); if (BENCH === "chat") seedBenchChats(); @@ -335,6 +339,7 @@ const artifactsMock = () => }); if (typeof window !== "undefined") (window as unknown as { __art: unknown }).__art = { bump: () => artifactsMock().then((m) => m.bumpArtifacts()), vdiff: () => artifactsMock().then((m) => m.bumpVdiff()) }; wireBrowserMock(emit); +wireSecretsMock(sessions, emit); wireMockServices({ sessions: (box) => (sessions[box] ??= []), path: (box, loc, wt) => locations[box]?.find((l) => l.name === loc)?.worktrees?.find((w) => w.name === wt)?.path ?? `/home/me/work/${loc}-${wt}`, @@ -713,6 +718,8 @@ function boxCall(box: string, method: string, path: string, body?: unknown): Pro if (phone) return phone; const turnCheck = turnCheckCall(box, method, path, body, delay); if (turnCheck) return turnCheck; + const secrets = secretsCall(box, method, path, body, { sessions, emit, delay }); + if (secrets) return secrets; const usage = usageCall(box, method, path, body, delay); if (usage) return usage; const wts = worktreesCall(box, method, path, body, { locations, sessions }, emit, delay); diff --git a/app/src/lib/notifications.ts b/app/src/lib/notifications.ts index d14d9c42f..1fe5445e2 100644 --- a/app/src/lib/notifications.ts +++ b/app/src/lib/notifications.ts @@ -55,7 +55,7 @@ const all: Channels = { centre: true, toast: true, system: true }; const quietly: Channels = { centre: true, toast: true, system: false }; export const CATEGORIES: CategoryInfo[] = [ - { id: "waiting", label: "An agent needs you", description: "It asked a question or wants permission.", needs: true, defaults: all }, + { id: "waiting", label: "An agent needs you", description: "It asked a question, wants permission or asks for a secret.", needs: true, defaults: all }, { id: "finished", label: "An agent is done", description: "Its turn ended, with or without changes.", needs: false, defaults: all }, { id: "review", label: "Work is ready for review", description: "An agent left changes in the review inbox.", needs: false, defaults: { centre: true, toast: false, system: false } }, { id: "flowFailed", label: "An automation failed", description: "A flow run ended with a failed step.", needs: true, defaults: all }, @@ -530,6 +530,10 @@ export function resolveFromEvent(e: BerthEvent) { case "secret.resolved": resolve((n) => n.category === "secret" && same(n) && n.key === secretKey(e.box, d.location, d.variable)); return; + // A secret request saved, declined or timed out: nobody is asked now. + case "secret.request.ended": + resolve((n) => n.category === "waiting" && same(n) && n.key === secretRequestKey(e.box, d.id)); + return; case "worktree.removed": case "worktree.setup.finished": resolve((n) => (n.category === "setupFailed" || (e.type === "worktree.removed" && n.category !== "notify")) && same(n) && (path ? n.path === path : n.project === d.location && n.worktree === d.name)); @@ -540,6 +544,7 @@ export function resolveFromEvent(e: BerthEvent) { export const serviceKey = (box?: string, location?: unknown, worktree?: unknown, service?: unknown) => `serviceFailed|${box}|${location}|${worktree}|${service}`; export const secretKey = (box?: string, location?: unknown, variable?: unknown) => `secret|${box}|${location ?? ""}|${variable}`; export const flowKey = (box?: string, flow?: unknown, scope?: unknown) => `flowFailed|${box}|${scope ?? ""}|${flow}`; +export const secretRequestKey = (box?: string, id?: unknown) => `waiting|${box}|secret|${id}`; // A waiting note also clears when the box reports its agent working again, // in case the event was missed. diff --git a/app/src/lib/secret-request.test.ts b/app/src/lib/secret-request.test.ts new file mode 100644 index 000000000..76d102abf --- /dev/null +++ b/app/src/lib/secret-request.test.ts @@ -0,0 +1,182 @@ +// node --experimental-strip-types --test src/lib/secret-request.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { SecretMeta, SecretRequest, Session } from "./api.ts"; +import { + askLine, + canDecline, + canSave, + cardReducer, + type CardState, + cardTitle, + deleteConfirm, + errorLine, + EXPIRY_OPTIONS, + expiresLine, + expiryOptions, + grantLine, + HONEST_LINE, + IDLE, + initialExpiry, + pendingSecrets, + replacedLine, + secretRequestOf, + sentExpiry, + storeLine, +} from "./secret-request.ts"; + +const staging: SecretRequest = { + id: "sr-1", + name: "ACME_TOKEN", + reason: "To call the acme staging API from the integration tests", + session: "shop-claude", + location: "shop", + store: "local", + store_label: "Encrypted file", + environment: "staging", + ttl: "24h", + created: "2026-10-29T10:00:00Z", + expires: "2026-10-29T11:00:00Z", + status: "pending", +}; + +const production: SecretRequest = { + id: "sr-2", + name: "ACME_PROD_DB", + reason: "Run the billing migration against production", + session: "billing-claude", + store: "gcp", + store_label: "GCP Secret Manager", + where: "acme-prod", + environment: "production", + grant: "billing@acme-prod.iam.gserviceaccount.com", + ttl: "1h", + created: "2026-10-29T09:00:00Z", + expires: "2026-10-29T10:00:00Z", + status: "pending", +}; + +const session = (over: Partial & { secret_request?: SecretRequest } = {}): Session => ({ name: "shop-claude", dir: "/home/me/shop", created: "2026-10-29T09:00:00Z", attached: 0, exited: false, agent: "claude", ...over }) as Session; + +test("a session's pending request is found, and nothing else is", () => { + assert.equal(secretRequestOf(session({ secret_request: staging })), staging); + assert.equal(secretRequestOf(session()), undefined); + assert.equal(secretRequestOf(undefined), undefined); + // An ended session has nobody left to give it to. + assert.equal(secretRequestOf(session({ secret_request: staging, exited: true })), undefined); + assert.equal(secretRequestOf(session({ secret_request: { ...staging, status: "saved" as "pending" } })), undefined); +}); + +test("a box's pending requests are its sessions', oldest first", () => { + const list = pendingSecrets([session({ name: "a", secret_request: staging }), session({ name: "b" }), session({ name: "c", secret_request: production })]); + assert.deepEqual( + list.map((r) => r.id), + ["sr-2", "sr-1"], + ); + assert.deepEqual(pendingSecrets(undefined), []); +}); + +test("the expiry picker offers four choices, preselecting the agent's suggestion", () => { + assert.deepEqual( + EXPIRY_OPTIONS.map((o) => [o.value, o.label]), + [ + ["", "No expiry"], + ["1h", "1 hour"], + ["24h", "24 hours"], + ["7d", "7 days"], + ], + ); + assert.equal(initialExpiry(staging), "24h"); + assert.equal(initialExpiry({ ...staging, ttl: undefined }), ""); + // Something the picker doesn't offer starts on no expiry. + assert.equal(initialExpiry({ ...staging, ttl: "30d" as "7d" }), ""); +}); + +test("production has no expiry picker and is always sent without one", () => { + assert.equal(expiryOptions(production), undefined); + assert.equal(expiryOptions(staging), EXPIRY_OPTIONS); + assert.equal(initialExpiry(production), ""); + assert.equal(sentExpiry(production, "7d"), ""); + assert.equal(sentExpiry(staging, "7d"), "7d"); +}); + +test("the card says the store, the grant, replace and the honest line in words", () => { + assert.equal(storeLine(staging), "Encrypted file"); + assert.equal(storeLine(production), "GCP Secret Manager · acme-prod"); + assert.equal(grantLine(production), "Grants billing@acme-prod.iam.gserviceaccount.com read access to this one secret"); + assert.equal(grantLine(staging), undefined); + assert.equal(cardTitle(staging), "ACME_TOKEN"); + assert.equal(cardTitle({ ...staging, replace: true }), "Replace ACME_TOKEN"); + assert.equal(replacedLine(staging), undefined); + assert.match(replacedLine({ ...staging, replace: true, replaced_at: new Date(Date.now() - 3 * 86_400_000).toISOString() })!, /^Replaces the one saved 3d ago$/); + assert.equal(HONEST_LINE, "The agent can use this in commands. A command that prints it puts it in the chat."); + assert.equal(askLine(staging), "ACME_TOKEN — To call the acme staging API from the integration tests"); +}); + +test("saving: idle, saving, saved; nothing is pressed twice", () => { + let s: CardState = IDLE; + assert.equal(canSave(s, 0), false, "nothing typed"); + assert.equal(canSave(s, 12), true); + s = cardReducer(s, { type: "save" }); + assert.deepEqual(s, { phase: "saving" }); + assert.equal(canSave(s, 12), false, "in flight"); + assert.equal(canDecline(s), false); + assert.equal(cardReducer(s, { type: "save" }), s); + assert.equal(cardReducer(s, { type: "decline" }), s); + s = cardReducer(s, { type: "saved", ref: "box://ACME_TOKEN" }); + assert.deepEqual(s, { phase: "saved", ref: "box://ACME_TOKEN" }); + // Over: nothing moves it again. + assert.equal(cardReducer(s, { type: "save" }), s); + assert.equal(cardReducer(s, { type: "failed", message: "late" }), s); + assert.equal(canSave(s, 12), false); +}); + +test("a failed save says why and that the value is only in the field, and can be tried again", () => { + let s = cardReducer(cardReducer(IDLE, { type: "save" }), { type: "failed", message: "missing permission secretmanager.secrets.create on project acme-staging." }); + assert.deepEqual(s, { phase: "error", during: "save", message: "missing permission secretmanager.secrets.create on project acme-staging." }); + assert.equal(errorLine(s, "devl"), "Couldn't save it: missing permission secretmanager.secrets.create on project acme-staging. The value is still in the field, and nowhere else."); + assert.equal(canSave(s, 12), true); + // The box away: its words, or ours. + assert.match(errorLine({ phase: "error", during: "save", message: "" }, "devl")!, /^Couldn't save it: devl didn't answer\./); + s = cardReducer(s, { type: "save" }); + assert.deepEqual(s, { phase: "saving" }); + assert.equal(errorLine(s, "devl"), undefined); +}); + +test("declining: idle, declining, declined; a failed decline can be tried again", () => { + let s = cardReducer(IDLE, { type: "decline" }); + assert.deepEqual(s, { phase: "declining" }); + assert.equal(cardReducer(s, { type: "saved", ref: "x" }), s, "a save's answer doesn't end a decline"); + const failed = cardReducer(s, { type: "failed", message: "gone" }); + assert.deepEqual(failed, { phase: "error", during: "decline", message: "gone" }); + assert.equal(errorLine(failed, "devl"), "Couldn't decline it: gone."); + assert.equal(canDecline(failed), true); + s = cardReducer(s, { type: "declined" }); + assert.deepEqual(s, { phase: "declined" }); + assert.equal(canDecline(s), false); +}); + +test("the state never holds a value: only phases, a reference and a message", () => { + const states: CardState[] = [IDLE, { phase: "saving" }, cardReducer({ phase: "saving" }, { type: "saved", ref: "gcp://projects/acme-prod/secrets/ACME_PROD_DB" })]; + for (const s of states) assert.ok(Object.keys(s).every((k) => ["phase", "ref", "during", "message"].includes(k))); +}); + +test("deleting a production secret needs its name typed; staging needs nothing", () => { + const prod: SecretMeta = { name: "ACME_PROD_DB", ref: "gcp://projects/acme-prod/secrets/ACME_PROD_DB", store: "gcp", store_label: "GCP Secret Manager", where: "acme-prod", environment: "production", created: "2026-10-01T00:00:00Z" }; + const stage: SecretMeta = { name: "ACME_TOKEN", ref: "box://ACME_TOKEN", store: "local", store_label: "Encrypted file", environment: "staging", created: "2026-10-01T00:00:00Z" }; + assert.equal(deleteConfirm(stage, ""), ""); + assert.equal(deleteConfirm(prod, ""), undefined); + assert.equal(deleteConfirm(prod, "ACME_PROD"), undefined); + assert.equal(deleteConfirm(prod, "acme_prod_db"), undefined, "the name exactly"); + assert.equal(deleteConfirm(prod, " ACME_PROD_DB "), "ACME_PROD_DB"); +}); + +test("a saved secret's expiry reads in words", () => { + const now = Date.parse("2026-10-29T10:00:00Z"); + assert.equal(expiresLine(undefined, now), "Never"); + assert.equal(expiresLine("2026-10-29T10:20:00Z", now), "In 20m"); + assert.equal(expiresLine("2026-10-30T09:00:00Z", now), "In 23h"); + assert.equal(expiresLine("2026-11-05T10:00:00Z", now), "In 7d"); + assert.equal(expiresLine("2026-10-29T09:00:00Z", now), "Expired"); +}); diff --git a/app/src/lib/secret-request.ts b/app/src/lib/secret-request.ts new file mode 100644 index 000000000..4d2f86e81 --- /dev/null +++ b/app/src/lib/secret-request.ts @@ -0,0 +1,151 @@ +import type { SecretExpiry, SecretMeta, SecretRequest, Session } from "@/lib/api"; +import { ago } from "./format.ts"; + +// A secret an agent asked the person for (berthd secret request), as its +// card draws it: in the chat, on the board's card, wherever a question +// shows. Pure, so the card and its tests share it. +// +// The value itself is never here. It lives only in the card's password +// field (components/conversation/secret-request-card.tsx), is read from it +// once on Save and goes to the box in that one request (boxApi.saveSecret); +// the field is then cleared and goes. Nothing below takes, keeps or returns +// it: not this state, a store, the transcript, an event, a log or the +// offline prompt queue. A failed save leaves it in the field, with no retry +// queued. + +// The line every card says, because it is true: a secret the agent can use +// is one a careless command can print. +export const HONEST_LINE = "The agent can use this in commands. A command that prints it puts it in the chat."; + +// The ask a session shows while it waits on a request (Session.ask.tool): +// its card answers it, never Allow or Deny. +export const SECRET_TOOL = "SecretRequest"; + +export const EXPIRY_OPTIONS: { value: SecretExpiry; label: string }[] = [ + { value: "", label: "No expiry" }, + { value: "1h", label: "1 hour" }, + { value: "24h", label: "24 hours" }, + { value: "7d", label: "7 days" }, +]; + +// secretRequestOf is the request a session waits on, while one is pending. +export function secretRequestOf(s: Session | undefined): SecretRequest | undefined { + const r = (s as (Session & { secret_request?: SecretRequest }) | undefined)?.secret_request; + return r && r.status === "pending" && !s?.exited ? r : undefined; +} + +// pendingSecrets are a box's sessions' pending requests, oldest first. +export function pendingSecrets(sessions: Session[] | undefined): SecretRequest[] { + return (sessions ?? []) + .map(secretRequestOf) + .filter((r): r is SecretRequest => !!r) + .sort((a, b) => a.created.localeCompare(b.created)); +} + +export const isProduction = (r: { environment?: string }): boolean => r.environment === "production"; + +// expiryOptions are the card's choices, or none: production is always no +// expiry, so it has no picker. +export const expiryOptions = (r: SecretRequest): typeof EXPIRY_OPTIONS | undefined => (isProduction(r) ? undefined : EXPIRY_OPTIONS); + +// initialExpiry is the choice the card starts on: what the agent suggests +// when it is one of the choices, else no expiry. +export function initialExpiry(r: SecretRequest): SecretExpiry { + if (isProduction(r)) return ""; + return EXPIRY_OPTIONS.some((o) => o.value === r.ttl) ? (r.ttl as SecretExpiry) : ""; +} + +// sentExpiry is what goes to the box: production ignores any choice. +export const sentExpiry = (r: SecretRequest, chosen: SecretExpiry): SecretExpiry => (isProduction(r) ? "" : chosen); + +// storeLine says where the value goes: "GCP Secret Manager · acme-staging". +export const storeLine = (r: { store_label: string; store: string; where?: string }): string => [r.store_label || r.store, r.where].filter(Boolean).join(" · "); + +export const grantLine = (r: SecretRequest): string | undefined => (r.grant ? `Grants ${r.grant} read access to this one secret` : undefined); + +export const cardTitle = (r: SecretRequest): string => (r.replace ? `Replace ${r.name}` : r.name); + +// replacedLine says when the secret it replaces was saved, when it says. +export function replacedLine(r: SecretRequest): string | undefined { + if (!r.replace) return undefined; + return r.replaced_at ? `Replaces the one saved ${ago(r.replaced_at)}` : "Replaces the one saved before"; +} + +// askLine is the request in the few words a notification or a card's +// summary has: "ACME_TOKEN — to call the staging API". +export const askLine = (r: { name: string; reason?: string }): string => (r.reason ? `${r.name} — ${r.reason}` : r.name); + +// ---- The card's state ---------------------------------------------------- +// +// idle → saving → saved, or → error (the value stays in the field) → saving +// again; idle → declining → declined, or → error. Saved and declined are +// the end: the box drops the request and the card goes with it. + +export type CardState = + | { phase: "idle" } + | { phase: "saving" } + | { phase: "declining" } + | { phase: "error"; during: "save" | "decline"; message: string } + | { phase: "saved"; ref: string } + | { phase: "declined" }; + +export type CardEvent = { type: "save" } | { type: "decline" } | { type: "saved"; ref: string } | { type: "declined" } | { type: "failed"; message: string }; + +export const IDLE: CardState = { phase: "idle" }; + +export function cardReducer(s: CardState, e: CardEvent): CardState { + if (s.phase === "saved" || s.phase === "declined") return s; + switch (e.type) { + case "save": + return busy(s) ? s : { phase: "saving" }; + case "decline": + return busy(s) ? s : { phase: "declining" }; + case "saved": + return s.phase === "saving" ? { phase: "saved", ref: e.ref } : s; + case "declined": + return s.phase === "declining" ? { phase: "declined" } : s; + case "failed": + return s.phase === "saving" || s.phase === "declining" ? { phase: "error", during: s.phase === "saving" ? "save" : "decline", message: e.message } : s; + } +} + +export const busy = (s: CardState): boolean => s.phase === "saving" || s.phase === "declining"; +export const done = (s: CardState): boolean => s.phase === "saved" || s.phase === "declined"; + +// canSave says Save may be pressed: something typed (its length only, as +// the card knows no more), nothing in flight, not over. +export const canSave = (s: CardState, typed: number): boolean => typed > 0 && !busy(s) && !done(s); +export const canDecline = (s: CardState): boolean => !busy(s) && !done(s); + +// errorLine is a failed save in words, saying where the value is: still in +// the field, and nowhere else. +export function errorLine(s: CardState, box: string): string | undefined { + if (s.phase !== "error") return undefined; + const why = (s.message || `${box} didn't answer`).replace(/\.\s*$/, ""); + return s.during === "save" ? `Couldn't save it: ${why}. The value is still in the field, and nowhere else.` : `Couldn't decline it: ${why}.`; +} + +// ---- Settings › Secrets ---------------------------------------------------- + +// needsTypedName says deleting a saved secret asks for its name typed: +// production secrets do. +export const needsTypedName = (s: SecretMeta): boolean => isProduction(s); + +// deleteConfirm is what to send as confirm, or undefined when Delete +// should stay disabled. A staging secret needs none (""). +export function deleteConfirm(s: SecretMeta, typed: string): string | undefined { + if (!needsTypedName(s)) return ""; + return typed.trim() === s.name ? s.name : undefined; +} + +// expiresLine says when a saved secret goes: "never", or in words. +export function expiresLine(expires: string | undefined, now = Date.now()): string { + if (!expires) return "Never"; + const ms = Date.parse(expires) - now; + if (!Number.isFinite(ms)) return expires; + if (ms <= 0) return "Expired"; + const h = ms / 3_600_000; + if (h < 1) return `In ${Math.max(1, Math.round(ms / 60_000))}m`; + if (h < 48) return `In ${Math.round(h)}h`; + return `In ${Math.round(h / 24)}d`; +} diff --git a/app/src/lib/transcript.ts b/app/src/lib/transcript.ts index 0605957b0..9f66d3c57 100644 --- a/app/src/lib/transcript.ts +++ b/app/src/lib/transcript.ts @@ -100,7 +100,7 @@ export interface AgentMessage { // One piece of work a reports on // (internal/transcript/report.go). export interface BerthReport { - kind: "task" | "turn" | "run"; + kind: "task" | "turn" | "run" | "secret"; session?: string; run?: string; template?: string; diff --git a/app/src/views/dashboard/agent-card.tsx b/app/src/views/dashboard/agent-card.tsx index 7794bfb11..7eea14fc0 100644 --- a/app/src/views/dashboard/agent-card.tsx +++ b/app/src/views/dashboard/agent-card.tsx @@ -2,6 +2,7 @@ import { HourglassIcon, SquareIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { AgentIcon } from "@/components/agent-glyph"; +import { SecretRequestCard } from "@/components/conversation/secret-request-card"; import { openOrchestrate, SessionActions } from "@/components/orchestrate/session-actions"; import { Tip } from "@/components/tip"; import { Button } from "@/components/ui/button"; @@ -12,6 +13,7 @@ import { boxApi } from "@/lib/api"; import { asksIn, chatCard, shownPrompt } from "@/lib/chat-card"; import { agentOf, atQuestion, ON_ITS_WORK } from "@/lib/derive"; import { errorMessage } from "@/lib/format"; +import { secretRequestOf } from "@/lib/secret-request"; import { useStore } from "@/lib/store"; import { isStructured } from "@/lib/structured"; import { cn } from "@/lib/utils"; @@ -81,7 +83,9 @@ export function AgentCard({ entry, selecting, selected, onSelect }: { entry: Ses }, []); const open = () => void focusSession(box, session.name); - const asking = waiting && choices.length > 0; + // A secret it asked for: its card here, in place of what it said. + const secret = state === "waiting" ? secretRequestOf(session) : undefined; + const asking = waiting && choices.length > 0 && !secret; // The question, without the options the chips below already show. const lines = asking && !chat ? (tail ?? []).filter((l) => !/^\s*(?:[❯›>]\s*)?\d[.)]\s/.test(l)) : tail; @@ -147,7 +151,8 @@ export function AgentCard({ entry, selecting, selected, onSelect }: { entry: Ses “{quote}” )} - {lines && lines.length > 0 && state !== "ready" && ( + {secret && } + {!secret && lines && lines.length > 0 && state !== "ready" && (
{lines.map((l, i) => (
diff --git a/app/src/views/home/widgets/agents.tsx b/app/src/views/home/widgets/agents.tsx index 7c53b04f0..092ec7111 100644 --- a/app/src/views/home/widgets/agents.tsx +++ b/app/src/views/home/widgets/agents.tsx @@ -8,6 +8,7 @@ import { type SessionEntry, useAllSessions } from "@/hooks/use-agent-counts"; import { lastOf } from "@/lib/last-of"; import { boxApi, type Session } from "@/lib/api"; import { QUESTION_TOOLS } from "@/lib/chat-card"; +import { SECRET_TOOL } from "@/lib/secret-request"; import { agentLabel, agentOf, worktreeOf } from "@/lib/derive"; import { parseScreen } from "@/lib/screen-status"; import { permissionChoices } from "@/lib/screen"; @@ -134,7 +135,8 @@ function WaitingRow({ r }: { r: AgentRow }) { const client = useStore((s) => s.client); const s = r.session; const tool = s.ask?.tool; - const question = !tool || QUESTION_TOOLS.test(tool); + // A secret it asked for is answered in its card: Answer opens it there. + const question = !tool || QUESTION_TOOLS.test(tool) || tool === SECRET_TOOL; // A permission's options are on the agent's screen (or, structured, on // its ask): read them while the row can be seen, as its own pane does. const ask = useAsk(r.box, s.name, visible && !preview && !question, s.state_since, s.ask); diff --git a/app/src/views/settings/find.tsx b/app/src/views/settings/find.tsx index 8b7053c7e..0a257d4f8 100644 --- a/app/src/views/settings/find.tsx +++ b/app/src/views/settings/find.tsx @@ -17,6 +17,7 @@ const INDEX: { id: SettingsSectionId; title: string; words: string }[] = [ { id: "phone", title: "Phone", words: "mobile qr code pairing ntfy push" }, { id: "agents", title: "Agents", words: "claude code codex models skills turn check cli install permissions bypass ask approvals allow chat" }, { id: "integrations", title: "Integrations", words: "linear github slack webhooks connect" }, + { id: "secrets", title: "Secrets", words: "api keys tokens passwords credentials secret manager gcp keychain store expiry delete requests" }, { id: "plugins", title: "Plugins", words: "marketplace install built in extensions allow" }, { id: "shortcuts", title: "Shortcuts", words: "keyboard keys bindings hotkeys" }, { id: "labs", title: "Labs", words: "experiments early harbour conversations diagnostics" }, diff --git a/app/src/views/settings/open.ts b/app/src/views/settings/open.ts index 4c97d3d08..be3fcda72 100644 --- a/app/src/views/settings/open.ts +++ b/app/src/views/settings/open.ts @@ -2,7 +2,7 @@ import { type View, useStore } from "@/lib/store"; // Settings' sections and how to open one, apart from the view itself, which // loads when it is first shown (App.tsx). -export type SettingsSectionId = "general" | "notifications" | "appearance" | "terminal" | "boxes" | "computers" | "phone" | "agents" | "integrations" | "plugins" | "shortcuts" | "labs" | "about" | "developer"; +export type SettingsSectionId = "general" | "notifications" | "appearance" | "terminal" | "boxes" | "computers" | "phone" | "agents" | "integrations" | "secrets" | "plugins" | "shortcuts" | "labs" | "about" | "developer"; // openSettings shows Settings, on a section when given one. export function openSettings(section?: SettingsSectionId) { diff --git a/app/src/views/settings/secrets-section.tsx b/app/src/views/settings/secrets-section.tsx new file mode 100644 index 000000000..843618e8d --- /dev/null +++ b/app/src/views/settings/secrets-section.tsx @@ -0,0 +1,269 @@ +import { KeyRoundIcon, Trash2Icon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { SimpleSelect } from "@/components/simple-select"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle } from "@/components/ui/alert-dialog"; +import { Input } from "@/components/ui/input"; +import { toastManager } from "@/components/ui/toast"; +import { BoxError } from "@/components/upgrade-box"; +import { boxApi, type SecretMeta, type SecretSettings, type SecretsList, type SecretStore } from "@/lib/api"; +import { useEventLog } from "@/lib/events"; +import { sessionName } from "@/lib/derive"; +import { ago, errorMessage } from "@/lib/format"; +import { deleteConfirm, expiresLine, isProduction, needsTypedName, storeLine } from "@/lib/secret-request"; +import { NONE, useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { Code, SettingsGroup, SettingsPage, SettingsRow } from "@/views/settings/rows"; + +// Settings › Secrets: per box, the secrets agents asked for and the person +// saved (berthd secret request), by name, where they are and who asked; +// never a value, which never comes back from the box. Delete one (a +// production one only with its name typed), and choose where a box saves +// them: its default store, and a project's own. +export function SecretsSection() { + const boxes = useStore((s) => s.status?.boxes ?? NONE); + const online = boxes.filter((b) => b.state === "online"); + return ( + + What agents asked you for with berthd secret request, kept on each box. Agents get a reference, never the value, and nothing here shows one. + + } + > + {online.length === 0 ?

No box is online. Its secrets are listed once it connects.

: online.map((b) => )} +
+ ); +} + +function BoxSecrets({ box }: { box: string }) { + const client = useStore((s) => s.client); + const [list, setList] = useState(); + // Saved or deleted anywhere (a card, another window, the CLI): read again. + const changed = useEventLog((s) => s.events.find((e) => e.box === box && /^secret\.(saved|deleted)$/.test(e.type))?.time); + const load = useCallback(() => { + if (!client) return; + boxApi.secrets(client, box).then( + (l) => setList(l), + (err) => setList({ error: errorMessage(err) }), + ); + }, [client, box]); + useEffect(load, [load, changed]); + + if (!list) { + return ( + + + + ); + } + if ("error" in list) { + return ( + +
+ +
+
+ ); + } + const secrets = list.secrets ?? []; + const stores = list.stores ?? []; + return ( +
+ + {list.errors?.map((e) => ( +

+ Couldn't list {storeLine({ store: e.store, store_label: stores.find((s) => s.id === e.store)?.label ?? e.store, where: e.where })}: {e.error} +

+ ))} + {secrets.length === 0 ? ( + + ) : ( + secrets.map((s) => ) + )} +
+ +
+ ); +} + +// SecretRow is one saved secret: its name and facts, and Delete. +function SecretRow({ box, s, onDeleted }: { box: string; s: SecretMeta; onDeleted(): void }) { + const [asking, setAsking] = useState(false); + // Who asked, by its work's name while the box still has it. + const sessions = useStore((st) => st.boxes[box]?.sessions); + const asker = s.session ? sessions?.find((x) => x.name === s.session) : undefined; + const who = asker ? sessionName(asker, { sessions, agent: true }) : (s.session ?? "an agent"); + const prod = isProduction(s); + const facts = [storeLine(s), `Expires: ${expiresLine(s.expires).toLowerCase()}`, s.created ? `Saved ${ago(s.created)}` : undefined].filter(Boolean).join(" · "); + return ( +
+
+
+ + {s.name} + {s.environment && ( + + {prod ? "Production" : s.environment === "staging" ? "Staging" : s.environment} + + )} +
+
{facts}
+ {(s.session || s.reason) && ( +
+ Asked by {who} + {s.location && ` in ${s.location}`} + {s.reason && : {s.reason}} +
+ )} +
+ + +
+ ); +} + +// DeleteSecret asks first; a production secret also wants its name typed, +// which goes to the box as confirm. +function DeleteSecret({ box, s, open, onOpenChange, onDeleted }: { box: string; s: SecretMeta; open: boolean; onOpenChange(o: boolean): void; onDeleted(): void }) { + const client = useStore((st) => st.client); + const [typed, setTyped] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + useEffect(() => { + if (!open) { + setTyped(""); + setError(undefined); + } + }, [open]); + const confirm = deleteConfirm(s, typed); + const go = async () => { + if (!client || confirm === undefined) return; + setBusy(true); + setError(undefined); + try { + await boxApi.deleteSecret(client, box, s.ref, needsTypedName(s) ? confirm : undefined); + onOpenChange(false); + toastManager.add({ type: "success", title: `Deleted ${s.name}`, description: storeLine(s) }); + onDeleted(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + }; + return ( + + + + Delete {s.name}? + + It goes from {storeLine(s)} on {box}. Agents that use {s.ref} can't read it any more. + {needsTypedName(s) && " It is a production secret: type its name to delete it."} + + {needsTypedName(s) && ( + setTyped(e.target.value)} + placeholder={s.name} + aria-label={`Type ${s.name} to delete it`} + data-testid="secret-delete-confirm" + className="mt-2 font-mono text-mono" + /> + )} + {error && ( +

+ {error} +

+ )} +
+ + }>Cancel + + +
+
+ ); +} + +const BOX_DEFAULT = ""; + +// WhereSaved is where a box saves the secrets agents ask for: its default +// store (and GCP project), and a project's own, which wins for its agents. +function WhereSaved({ box, stores, settings, onSaved }: { box: string; stores: SecretStore[]; settings: SecretSettings; onSaved(): void }) { + const client = useStore((s) => s.client); + const locations = useStore((s) => s.boxes[box]?.locations); + const [draft, setDraft] = useState(settings); + const [busy, setBusy] = useState(false); + useEffect(() => setDraft(settings), [settings]); + const dirty = JSON.stringify(clean(draft)) !== JSON.stringify(clean(settings)); + const options = stores.map((s) => ({ value: s.id, label: s.label })); + const gcp = (id?: string) => id === "gcp"; + const setLocation = (name: string, patch: { store?: string; gcp_project?: string }) => + setDraft((d) => ({ ...d, locations: { ...d.locations, [name]: { ...d.locations?.[name], ...patch } } })); + + const save = async () => { + if (!client) return; + setBusy(true); + try { + await boxApi.putSecretSettings(client, box, clean(draft)); + toastManager.add({ type: "success", title: `Saved where ${box} keeps secrets` }); + onSaved(); + } catch (err) { + toastManager.add({ type: "error", title: `Secrets on ${box}`, description: errorMessage(err) }); + } finally { + setBusy(false); + } + }; + + return ( + void save()}> + Save + + } + > + + {gcp(draft.store) && setDraft((d) => ({ ...d, gcp_project: e.target.value }))} placeholder="GCP project" aria-label={`GCP project on ${box}`} />} + setDraft((d) => ({ ...d, store: v }))} aria-label={`Default store on ${box}`} /> + + {(locations ?? []).map((l) => { + const own = draft.locations?.[l.name]; + return ( + + {gcp(own?.store) && setLocation(l.name, { gcp_project: e.target.value })} placeholder="GCP project" aria-label={`GCP project for ${l.name} on ${box}`} />} + setLocation(l.name, { store: v || undefined, gcp_project: gcp(v) ? own?.gcp_project : undefined })} aria-label={`Store for ${l.name} on ${box}`} /> + + ); + })} + + ); +} + +// clean drops a project's empty choice, so "Box default" is no entry, and +// a GCP project where the store isn't GCP. +function clean(s: SecretSettings): SecretSettings { + const locations: NonNullable = {}; + for (const [name, l] of Object.entries(s.locations ?? {})) { + if (!l?.store) continue; + locations[name] = l.store === "gcp" && l.gcp_project?.trim() ? { store: l.store, gcp_project: l.gcp_project.trim() } : { store: l.store }; + } + const out: SecretSettings = { store: s.store }; + if (s.store === "gcp" && s.gcp_project?.trim()) out.gcp_project = s.gcp_project.trim(); + if (Object.keys(locations).length) out.locations = locations; + return out; +} diff --git a/app/src/views/settings/settings-view.tsx b/app/src/views/settings/settings-view.tsx index 5d067d932..01c8d4720 100644 --- a/app/src/views/settings/settings-view.tsx +++ b/app/src/views/settings/settings-view.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, BellIcon, BotIcon, CableIcon, FlaskConicalIcon, InfoIcon, KeyboardIcon, LaptopIcon, PaletteIcon, PuzzleIcon, ServerIcon, SlidersHorizontalIcon, SmartphoneIcon, SquareTerminalIcon, WrenchIcon } from "lucide-react"; +import { ArrowLeftIcon, BellIcon, BotIcon, CableIcon, FlaskConicalIcon, InfoIcon, KeyboardIcon, KeyRoundIcon, LaptopIcon, PaletteIcon, PuzzleIcon, ServerIcon, SlidersHorizontalIcon, SmartphoneIcon, SquareTerminalIcon, WrenchIcon } from "lucide-react"; import { type ComponentType, useEffect } from "react"; import { ink, inkScale } from "@/components/sidebar/ink"; @@ -18,6 +18,7 @@ import { LabsSection } from "@/views/settings/labs-section"; import { NotificationsSection } from "@/views/settings/notifications-section"; import { PhoneSection } from "@/views/settings/phone-section"; import { PluginsSection } from "@/views/settings/plugins-section"; +import { SecretsSection } from "@/views/settings/secrets-section"; import { ShortcutsSection } from "@/views/settings/shortcuts-section"; import { TerminalSection } from "@/views/settings/terminal-section"; @@ -35,6 +36,7 @@ const SECTIONS: { id: SettingsSectionId; title: string; icon: ComponentType<{ cl { id: "phone", title: "Phone", icon: SmartphoneIcon, Component: PhoneSection }, { id: "agents", title: "Agents", icon: BotIcon, Component: AgentsSection }, { id: "integrations", title: "Integrations", icon: CableIcon, Component: IntegrationsSection }, + { id: "secrets", title: "Secrets", icon: KeyRoundIcon, Component: SecretsSection }, { id: "plugins", title: "Plugins", icon: PuzzleIcon, Component: PluginsSection }, { id: "shortcuts", title: "Shortcuts", icon: KeyboardIcon, Component: ShortcutsSection }, { id: "labs", title: "Labs", icon: FlaskConicalIcon, Component: LabsSection }, @@ -46,7 +48,7 @@ const SECTIONS: { id: SettingsSectionId; title: string; icon: ComponentType<{ cl // (Labs, Developer, About) sit at the bottom. const GROUPS: { title?: string; ids: SettingsSectionId[] }[] = [ { title: "Shipyard", ids: ["general", "appearance", "notifications", "shortcuts"] }, - { title: "Agents", ids: ["agents", "terminal", "integrations", "plugins"] }, + { title: "Agents", ids: ["agents", "terminal", "integrations", "secrets", "plugins"] }, { title: "Machines", ids: ["boxes", "computers", "phone"] }, ]; const BOTTOM: SettingsSectionId[] = ["labs", "developer", "about"]; diff --git a/cmd/berthd/main.go b/cmd/berthd/main.go index d2f48d3f4..0ded02e15 100644 --- a/cmd/berthd/main.go +++ b/cmd/berthd/main.go @@ -62,9 +62,10 @@ const usage = `berthd — the berth daemon for a development box berthd doctor [--json] Check this box's setup and how to fix it berthd version Print this build's version berthd session attach NAME Attach to a session in this terminal - berthd secret exec [--socket PATH] -- PROGRAM [ARGS...] + berthd secret exec [--socket PATH] [--env VAR=REF]... [--file VAR=REF]... -- PROGRAM [ARGS...] Resolve secret references, then run PROGRAM (sessions and - services use it) + services use it; --env/--file: a saved secret, box://NAME, + for one command, as a variable or a temporary file) berthd secret signin [--check] Sign op in to 1Password here, for berthd's op:// reads (--check: only say whether it can read now) berthd mcp A stdio MCP server of berth's tools for agents on this box @@ -457,6 +458,9 @@ func serve(b boxHome, args []string) error { // Agents hear back about work they started, after the ledger has it. bx.Reports = &box.Notifier{Path: filepath.Join(b.dir, "notify.json")} go bx.Reports.Run(ctx, bx) + // Agents' asks for a secret, answered in the app (secretrequests.go). + bx.SecretRequests = &box.SecretRequests{Path: filepath.Join(b.dir, "secret-requests.json")} + go bx.RunSecretSweeps(ctx) go bx.RunRepoHooks(ctx, logger) // Runs that were going when berthd stopped carry on from their journals. bx.Runs.Resume(ctx) diff --git a/cmd/berthd/secret.go b/cmd/berthd/secret.go index a80064748..fc3c6dc8a 100644 --- a/cmd/berthd/secret.go +++ b/cmd/berthd/secret.go @@ -8,7 +8,10 @@ import ( "io" "os" "os/exec" + "os/signal" "path/filepath" + "regexp" + "sort" "strings" "syscall" "time" @@ -25,16 +28,25 @@ import ( // behind it too, since tmux would otherwise take the values as arguments. A // reference that cannot be resolved leaves its variable unset, and the box // hears about it and announces it as a secret.failed event. +// +// An agent runs it itself to use a saved secret in one command: +// --env K=box://NAME puts the value in K for that command alone, and +// --file K=box://NAME writes it to a private temporary file, puts the +// file's path in K, and removes the file when the command ends. These +// must resolve: one that can't stops the command. func secretExec(b boxHome, args []string) error { fs := flag.NewFlagSet("secret exec", flag.ContinueOnError) fs.SetOutput(io.Discard) socket := fs.String("socket", b.socket(), "the box's local API socket") + var envRefs, fileRefs refFlags + fs.Var(&envRefs, "env", "VAR=REF: the value of REF in VAR, for this command") + fs.Var(&fileRefs, "file", "VAR=REF: a temporary file holding REF's value, its path in VAR") if err := fs.Parse(args); err != nil { return err } argv := fs.Args() if len(argv) == 0 { - return errors.New("usage: berthd secret exec [--socket PATH] -- PROGRAM [ARGS...]") + return errors.New("usage: berthd secret exec [--socket PATH] [--env VAR=REF]... [--file VAR=REF]... -- PROGRAM [ARGS...]") } refs := map[string]string{} for _, k := range strings.Split(os.Getenv(box.SecretVarsEnv), ",") { @@ -60,11 +72,125 @@ func secretExec(b boxHome, args []string) error { if len(rep.Results) > 0 { reportSecrets(*socket, rep) } + asked := map[string]string{} + for k, ref := range envRefs { + asked[k] = ref + } + for k, ref := range fileRefs { + asked[k] = ref + } + got, bad := b.boxSecrets().ResolveAll(ctx, asked, nil) + if len(bad) > 0 { + var msgs []string + for k, err := range bad { + msgs = append(msgs, fmt.Sprintf("%s (%s): %s", k, asked[k], err)) + } + sort.Strings(msgs) + return errors.New("could not resolve " + strings.Join(msgs, "; ")) + } + for k := range envRefs { + os.Setenv(k, got[k]) + } bin, err := exec.LookPath(argv[0]) if err != nil { return err } - return syscall.Exec(bin, argv, os.Environ()) + if len(fileRefs) == 0 { + return syscall.Exec(bin, argv, os.Environ()) + } + files := map[string]string{} + for k := range fileRefs { + files[k] = got[k] + } + code, err := runWithFiles(bin, argv, files) + if err != nil { + return err + } + if code != 0 { + return exitCode(code) + } + return nil +} + +// refFlags is a repeated VAR=REF flag. +type refFlags map[string]string + +func (r *refFlags) String() string { return "" } + +func (r *refFlags) Set(v string) error { + k, ref, ok := strings.Cut(v, "=") + if !ok || !envVarName.MatchString(k) { + return fmt.Errorf("%q is not VAR=REF", v) + } + if err := box.ValidateSecretRef(ref); err != nil { + return err + } + if *r == nil { + *r = refFlags{} + } + (*r)[k] = ref + return nil +} + +var envVarName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// secretFileDir is where --file writes: a folder in memory when the box +// has one, so the value never reaches a disk. +func secretFileDir() string { + if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" { + if st, err := os.Stat(d); err == nil && st.IsDir() { + return d + } + } + if st, err := os.Stat("/dev/shm"); err == nil && st.IsDir() { + return "/dev/shm" + } + return os.TempDir() +} + +// runWithFiles runs a program with each value in a file of its own, +// readable by this user only, and each variable naming its file. The files +// go when the program ends, and when this is stopped by a signal, which it +// passes on to the program first. +func runWithFiles(bin string, argv []string, values map[string]string) (int, error) { + dir, err := os.MkdirTemp(secretFileDir(), "berth-secret-") + if err != nil { + return 0, err + } + defer os.RemoveAll(dir) + env := os.Environ() + for k, v := range values { + p := filepath.Join(dir, k) + if err := os.WriteFile(p, []byte(v), 0o600); err != nil { + return 0, err + } + env = append(env, k+"="+p) + } + cmd := exec.Command(bin, argv[1:]...) + cmd.Env, cmd.Stdin, cmd.Stdout, cmd.Stderr = env, os.Stdin, os.Stdout, os.Stderr + sigs := make(chan os.Signal, 4) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) + defer signal.Stop(sigs) + if err := cmd.Start(); err != nil { + return 0, err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + for { + select { + case s := <-sigs: + cmd.Process.Signal(s) + case err := <-done: + var ee *exec.ExitError + if errors.As(err, &ee) { + if st, ok := ee.Sys().(syscall.WaitStatus); ok && st.Signaled() { + return 128 + int(st.Signal()), nil + } + return ee.ExitCode(), nil + } + return 0, err + } + } } // reportSecrets tells the box, if berthd is running, which variables @@ -86,7 +212,14 @@ func (b boxHome) opSessionFile() string { return filepath.Join(b.dir, "op-sessio // boxSecrets is how this box reads secrets, outside berthd serve too. func (b boxHome) boxSecrets() *box.Secrets { host, _ := os.Hostname() - return &box.Secrets{SessionFile: b.opSessionFile(), Box: host} + return &box.Secrets{SessionFile: b.opSessionFile(), Box: host, Stores: b.secretStores()} +} + +// secretStores are the secrets saved through secret cards, which berthd +// serve saves and any process on the box reads. +func (b boxHome) secretStores() *box.SecretStores { + host, _ := os.Hostname() + return &box.SecretStores{Dir: b.dir, Box: host} } // secretSignin signs the box's op in to 1Password, in the terminal it runs diff --git a/cmd/berthd/secret_test.go b/cmd/berthd/secret_test.go index 5f64936dd..e8cba6cd3 100644 --- a/cmd/berthd/secret_test.go +++ b/cmd/berthd/secret_test.go @@ -1,8 +1,12 @@ package main import ( + "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" + "fmt" "net" "net/http" "os" @@ -10,7 +14,9 @@ import ( "path/filepath" "strings" "sync" + "syscall" "testing" + "time" "github.com/cosscom/shipyard/internal/box" ) @@ -158,3 +164,111 @@ func withStdin(t *testing.T, input string, f func()) { defer func() { os.Stdin = old; r.Close() }() f() } + +// TestSecretExecArgsHelper runs secret exec with the arguments the parent +// test gives, against its box folder. +func TestSecretExecArgsHelper(t *testing.T) { + if os.Getenv("BERTH_TEST_SECRET_ARGS") == "" { + t.Skip("run by the --env/--file tests") + } + var args []string + json.Unmarshal([]byte(os.Getenv("BERTH_TEST_SECRET_ARGS")), &args) + err := secretExec(boxHome{dir: os.Getenv("BERTH_TEST_BOX_DIR")}, args) + var code exitCode + if errors.As(err, &code) { + os.Exit(int(code)) + } + if err != nil { + fmt.Fprintln(os.Stderr, "berthd:", err) + os.Exit(1) + } + os.Exit(0) +} + +// savedSecretBox is a box folder with ACME_TOKEN saved in its encrypted +// file, as a secret card would have saved it. +func savedSecretBox(t *testing.T, value string) string { + t.Helper() + dir := t.TempDir() + stores := &box.SecretStores{Dir: dir, Box: "devbox", Bins: map[string]string{"security": "/nonexistent", "secret-tool": "/nonexistent"}} + st, err := stores.Open("local", "") + if err != nil { + t.Fatal(err) + } + if _, err := st.Save(context.Background(), box.SecretSave{Name: "ACME_TOKEN", Value: value}); err != nil { + t.Fatal(err) + } + return dir +} + +func secretExecCmd(dir string, args ...string) *exec.Cmd { + raw, _ := json.Marshal(args) + cmd := exec.Command(os.Args[0], "-test.run=^TestSecretExecArgsHelper$") + cmd.Env = append(os.Environ(), "BERTH_TEST_SECRET_ARGS="+string(raw), "BERTH_TEST_BOX_DIR="+dir, "BERTH_SECRET_VARS=") + return cmd +} + +func TestSecretExecHandsASavedSecretToOneCommand(t *testing.T) { + const value = "acme-sentinel-exec-31" + dir := savedSecretBox(t, value) + sum := sha256.Sum256([]byte(value)) + want := hex.EncodeToString(sum[:]) + out, err := secretExecCmd(dir, "--env", "T=box://ACME_TOKEN", "--file", "F=box://ACME_TOKEN", "--", "/bin/sh", "-c", + `printf %s "$T" | sha256sum | cut -d' ' -f1; sha256sum < "$F" | cut -d' ' -f1; echo "$F"; stat -c %a "$F" 2>/dev/null || stat -f %Lp "$F"`).Output() + if err != nil { + t.Fatalf("%v: %s", err, out) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) != 4 || lines[0] != want || lines[1] != want || lines[3] != "600" { + t.Fatalf("the command saw %q", out) + } + if _, err := os.Stat(lines[2]); !os.IsNotExist(err) { + t.Fatalf("the file %s is still there", lines[2]) + } + // One that can't be resolved stops the command. + out, err = secretExecCmd(dir, "--env", "T=box://NOT_SAVED", "--", "/bin/echo", "ran").CombinedOutput() + if err == nil || strings.Contains(string(out), "ran") || !strings.Contains(string(out), "NOT_SAVED is not saved") { + t.Fatalf("an unresolved reference: %v %s", err, out) + } + for _, bad := range []string{"T=box://lower", "T=plain", "1T=box://ACME_TOKEN", "T=gcp://acme/x"} { + var r refFlags + if r.Set(bad) == nil { + t.Errorf("--env %s was taken", bad) + } + } + var r refFlags + if err := r.Set("K=gcp://projects/acme-staging/secrets/ACME_TOKEN"); err != nil || r["K"] != "gcp://projects/acme-staging/secrets/ACME_TOKEN" { + t.Fatalf("a gcp reference: %v %v", err, r) + } +} + +func TestSecretExecFileGoesWhenTheCommandIsStopped(t *testing.T) { + dir := savedSecretBox(t, "acme-sentinel-term") + mark := filepath.Join(t.TempDir(), "path") + cmd := secretExecCmd(dir, "--file", "F=box://ACME_TOKEN", "--", "/bin/sh", "-c", `echo "$F" > `+mark+`; exec sleep 30`) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + var path string + for deadline := time.Now().Add(10 * time.Second); path == ""; time.Sleep(50 * time.Millisecond) { + if b, _ := os.ReadFile(mark); len(b) > 0 { + path = strings.TrimSpace(string(b)) + } + if time.Now().After(deadline) { + cmd.Process.Kill() + t.Fatal("the command never started") + } + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("the file isn't there while it runs: %v", err) + } + cmd.Process.Signal(syscall.SIGTERM) + err := cmd.Wait() + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != 128+int(syscall.SIGTERM) { + t.Fatalf("stopped: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("the file %s outlived a SIGTERM", path) + } +} diff --git a/docs/changelog.mdx b/docs/changelog.mdx index cc4ef4e5a..36230a0ca 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -15,6 +15,17 @@ again. the ones you're using stand out. Switching to one of those opens New task in it; a project whose agents are only idle or done just switches to where you were, as before. +- **Agents ask for secrets through a masked card.** Instead of having an + API key pasted into the chat, an agent runs `berthd secret request NAME + --reason …` (or the `berth_secret_request` tool), and a card shows + wherever a question does: a masked field, the agent's reason, where it + will be saved, and Save securely or Decline. The value goes from the app + to the box only, never into the chat, an event or a log, and the agent + gets a reference such as `box://ACME_TOKEN` to use in one command with + `berthd secret exec --env` or `--file`. Saved secrets live in the + Keychain on a Mac, a Secret Service or an encrypted file on Linux, or + Google Cloud Secret Manager, and Settings → Secrets lists and deletes + them. [Secrets](/guides/secrets#when-an-agent-asks-for-a-secret) - **Agents come back after a reboot.** Starting a chat agent again after its box restarted ran its old command with its last word cut off, so Claude refused to start ("argument missing") and the tab only said it had diff --git a/docs/concepts/security.mdx b/docs/concepts/security.mdx index c2aecefc2..905d1fb04 100644 --- a/docs/concepts/security.mdx +++ b/docs/concepts/security.mdx @@ -265,11 +265,20 @@ output. That data is only ever text: ## Secrets and prompts - A project's environment can name secrets (`op://…` for 1Password, - `env://…` for a variable in berthd's own environment) instead of holding - them. Values are read when needed and kept in memory: sessions and - services read theirs inside their own process (`berthd secret exec`), so - a value is never on a command line. No value is written to disk, a log, an - event or the API; see [secrets](/guides/secrets#how-values-are-handled). + `env://…` for a variable in berthd's own environment, `box://…` and + `gcp://…` for ones saved through a secret card) instead of holding them. + Values are read when needed and kept in memory: sessions and services + read theirs inside their own process (`berthd secret exec`), so a value is + never on a command line. No value is written to a log, an event or the + API, and none to disk except into the store a person saved it to (the + Keychain, a Secret Service, or the box's encrypted file); see + [secrets](/guides/secrets#how-values-are-handled). +- An agent asks for a secret by name (`berthd secret request`), and the + value goes from the app to the box in one call that the box's own socket + can't make, so an agent can neither set nor read a value through the API. + It gets a reference, and uses it in one command with + `berthd secret exec --env`; an agent that deliberately prints it can still + see it, as the card says. - A prompt typed into a session (`berth session send`, a flow's `prompt` step, the phone) never appears in events or logs: `session.sent` carries only the session's name, and agent tool hooks forward only identifiers diff --git a/docs/guides/secrets.mdx b/docs/guides/secrets.mdx index 8dd0dac03..4a1b7bd93 100644 --- a/docs/guides/secrets.mdx +++ b/docs/guides/secrets.mdx @@ -1,6 +1,6 @@ --- -title: Secrets with 1Password -description: Name a secret in a project's environment instead of holding it, and let each box read it when needed. +title: Secrets +description: Name a secret in a project's environment instead of holding it, let agents ask you for one through a masked card, and let each box read it when needed. --- An `env` value in [`.berth/config.json`](/guides/project-config), a @@ -25,6 +25,11 @@ one in it: with the 1Password CLI on the box: `op read`. - `env://NAME` passes on a variable from berthd's own environment, under whatever name the project wants. +- `box://NAME` is a secret saved on the box through a + [secret card](#when-an-agent-asks-for-a-secret). +- `gcp://projects/PROJECT/secrets/NAME` (optionally `/versions/N`) is one + saved in [Google Cloud Secret Manager](#gcp-secret-manager), read with the + box's own gcloud. ## Setting up 1Password on a box @@ -54,10 +59,11 @@ box reports only whether it could read it and how long the value is. The box reads a reference when it builds a worktree's environment for a hook, a flow, a setup script or `exec`, and keeps the value in memory for -five minutes. It never writes a value to disk or puts one in a log, an -event, the API or an error: config, `GET /v1/locations/{name}/config` and -`GET /v1/env` show the reference. A value is not expanded, so a `$` in a -password stays a `$`. +five minutes. It never puts a value in a log, an event, the API or an +error, and writes one to disk only into the store you saved it to (see +[where saved secrets are kept](#where-saved-secrets-are-kept)): config, +`GET /v1/locations/{name}/config` and `GET /v1/env` show the reference. A +value is not expanded, so a `$` in a password stays a `$`. Terminals, agents and services don't get values from berthd at all: tmux takes a session's environment as command-line arguments, which other @@ -86,3 +92,125 @@ is remembered for 30 seconds, so a broken reference does not run `op` for every hook. When it reads again, the box sends `secret.resolved`. A kit that uses `op://` should list `{ "tool": "op" }` in `requires`. + +## When an agent asks for a secret + +An agent that needs a key or a token can ask you for it instead of having +it pasted into the chat, where it would sit in the transcript, the agent's +context and the host's log: + +```sh +berthd secret request ACME_TOKEN --reason "Deploy the acme app to staging" +``` + +Or with the `berth_secret_request` tool, which takes the same fields. Either +way it returns at once with a request ID (`--wait 90s` waits at most 90 +seconds), and the session shows as needing you wherever a question shows: +its chat, its card on the dashboard and a notification. The card has a +masked field with a reveal toggle, the name, the agent's reason as it wrote +it, the session, where the value will be saved and for how long, and +**Save securely** or **Decline**. It also says plainly: the agent can use +this in commands, and a command that prints it puts it in the chat. + +**Save securely** sends the value from the app to the box in one direct +call over the paired, pinned connection. It is never in the transcript, an +event, a log, a hook's data or the laptop's offline queue; if the box can't +be reached, the card says so and the value stays in the field. The agent +hears back with a `` holding a reference such as +`box://ACME_TOKEN`, never the value. A request nobody answers ends after an +hour, and the agent hears that too. + +| Option | | +|---|---| +| `--reason WHY` | Shown on the card as written. Required | +| `--new` | Ask for a new value even when one is saved: the card says **Replace** and when the old one was saved. Without it, a saved, unexpired secret's reference comes back at once, with no card | +| `--store local\|gcp` | Where to save it. Default: the project's choice, else the box's, set in Settings → Secrets | +| `--environment staging\|production` | Production shows the environment, the GCP project and any grant in a warning colour, and never expires | +| `--grant SA_EMAIL` | GCP only: give that service account read access to this one secret, shown on the card first | +| `--ttl 1h\|24h\|7d` | Preselect a lifetime. Saved secrets are kept until deleted unless you pick one on the card | + +Names are environment-variable style (`ACME_TOKEN`). Saved secrets are +box-wide: any agent on the box can use any secret saved there. + +### Using a saved secret + +A saved secret is handed out only on demand, never through a session's +environment: + +```sh +# The value in a variable, for one command: +berthd secret exec --env ACME_TOKEN=box://ACME_TOKEN -- ./deploy.sh + +# For a CLI that wants a token file: a private temporary file (in +# $XDG_RUNTIME_DIR or /dev/shm when the box has one), its path in the +# variable, removed when the command ends, signals included: +berthd secret exec --file GOOGLE_APPLICATION_CREDENTIALS=box://ACME_SA_KEY -- gcloud … +``` + +A reference that can't be read stops the command with why. `box://` and +`gcp://` also work wherever `op://` does: a project's `env`, the box's +`env.json` and `berth secret test`. There is no command that prints a value. + +### Where saved secrets are kept + +The **Local** store, the default, keeps them on the box: + +- **macOS:** the login Keychain, written through `security` with the value + on its standard input, never in its arguments. +- **Linux** with a Secret Service on the session bus (GNOME Keyring, + KeePassXC): there, through libsecret's `secret-tool`, the value on its + standard input. +- **Otherwise:** an encrypted file, `box/secrets/values.json` in the state + directory, each value sealed with XChaCha20-Poly1305 under a key in its + own file, `box/secret.key`, readable by you alone. This guards against a + leaked folder, a backup or a grep. It does not guard against another + program running as the same user, which can read the key too. + +Beside them the box keeps what it saved (name, store, environment, who +asked and why, when it expires; never a value) in `box/secrets/index.json`, +and deletes expired secrets every minute. Settings → Secrets lists them per +box, with **Delete**; a production secret needs its name typed first. + +To remove saved secrets by hand (say after going back to a build without +secret cards): delete them in Settings → Secrets first, or remove the +Keychain items whose service is `dev.berth.secrets`, run +`secret-tool clear service dev.berth.secrets name NAME`, or delete +`box/secrets/` and `box/secret.key`. + +### GCP Secret Manager + +Set GCP as the box's store, or one project's, in Settings → Secrets, with a +GCP project. berthd reads and writes with whatever gcloud is signed in as +on the box, and **never changes a project's IAM**. A missing permission +fails on the card, naming it. The least it needs, as a custom role: + +- `secretmanager.secrets.create`, `secretmanager.secrets.get`, + `secretmanager.secrets.list` +- `secretmanager.versions.add`, `secretmanager.versions.access` +- `secretmanager.secrets.delete`, to delete from Settings +- `secretmanager.secrets.setIamPolicy`, only for `--grant` + +On **Save securely** the box creates a secret with ID `NAME` and the labels +`shipyard=true`, `shipyard-environment`, `shipyard-location` and +`shipyard-box`, then adds the value as a version. It refuses to add a +version to a secret by that name without the `shipyard` label: one it +didn't make is left alone. A labelled one gets a new version, which is how +`--new` rotates it. `--grant` adds `roles/secretmanager.secretAccessor` for +that service account on that one secret only; mounting it on a service +(`gcloud run services update --update-secrets …`) stays the agent's own +step. The agent gets `gcp://projects/PROJECT/secrets/NAME`. Settings lists +the secrets labelled `shipyard=true` in each project the box or a project +names. + +### Adding a provider + +Stores are providers behind one Go interface, `SecretStore` in +`internal/box/secretstore.go`: save, resolve, list and delete, with a label +for the card. A provider is one file that registers itself in an `init` +with `registerSecretStore`: its id (what `--store` names), its reference +scheme, what it can do (expiry, labels, grant), how to validate a +reference, and how to open its store at a place (a GCP project, a Vercel +team). `secretstore_gcp.go` is the example. The card, the API, +`berthd secret exec` and Settings only go through the interface, so a new +provider changes nothing else; `TestAThirdProviderPlugsInFromItsOwnFile` +shows one doing it. diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index 3888a0bb5..45609c51d 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -157,6 +157,14 @@ failed after it went out (the box may have acted on it). The box API: | `GET doctor` | `Check[]` | | `POST secrets/test` | `{ ref }` → `{ ok, length?, error? }`: resolves a [secret reference](/guides/secrets) on the box now (`op://…` or `env://…`), skipping its cache, and reports whether it could and the value's length, never the value. A malformed reference is `ok: false` with why | | `POST secrets/report` | Only on the box's own socket: what `berthd secret exec` resolved for a session or service, which variables resolved and which failed and why, never values | +| `POST secrets/requests` | Only on the box's own socket, from an agent session (`X-Berth-Caller`): `{ name, reason, new?, store?, environment?, grant?, ttl? }` asks the person for a [secret](/guides/secrets#when-an-agent-asks-for-a-secret). Returns a pending request, or `{ status: "saved", ref, reused: true }` for one saved and unexpired | +| `GET secrets/requests` | The pending requests. A session waiting on one also carries it as `secret_request`, with `agent_state` `waiting` and `soft_wait` | +| `GET secrets/requests/{id}` | One request: `pending`, `saved` (with `ref`), `declined` or `timed_out` | +| `POST secrets/requests/{id}/save` | Refused on the box's own socket: `{ value, expires: "" \| "1h" \| "24h" \| "7d" }` saves the value to the request's store and answers `{ ref }`. The value is never echoed, logged or announced | +| `POST secrets/requests/{id}/decline` | Refused on the box's own socket: the agent hears it was declined | +| `GET secrets` | Saved secrets from every configured store, names and metadata only, with the stores, the settings and any store that couldn't be listed | +| `POST secrets/delete` | Refused on the box's own socket: `{ ref, confirm? }`; a production secret needs its name as `confirm` | +| `GET secrets/settings`, `PUT secrets/settings` | The box's default store and GCP project, and each project's (kept in the box's own config for the location). `PUT` is refused on the box's own socket | | `POST sessions/{name}/send` | `{ text, enter?, when?, force?, idem_key? }` → `{ sent, queued?, duplicate?, turn?, seq?, at }`: types a prompt, or with `when: "idle"` holds it until the agent is idle. With `when`, refused (409) while the agent waits for someone unless `force` ([orchestration](/guides/orchestration#send)) | | `GET sessions/{name}/wait?for=finished,waiting&after=TIME&timeout=10m` | `{ state, timed_out, turn? }`: `after` is an RFC 3339 time, placed in the box's journal order; `timeout` a duration, 1s to 1h (default 10m) | | `GET sessions/{name}/turns?limit=N` | `Turn[]`, oldest first: the session's last `N` turns (default 20). A wait's `ask` is what the agent asked for, from its hooks: `{ tool?, input?, why?, message? }`, each at most 300 characters (the command or the file's path, never what it would write) | diff --git a/docs/reference/berthd.mdx b/docs/reference/berthd.mdx index 7ed1cb934..7e0b4731a 100644 --- a/docs/reference/berthd.mdx +++ b/docs/reference/berthd.mdx @@ -226,8 +226,9 @@ refuse a helper, and a `before:helper.attach` gate a look in a worktree's | Command | What it does | | --- | --- | | `berthd skills [list\|install\|uninstall] [SKILL...] [--agent claude\|codex\|all] [--target user\|project] [--location LOC] [--commit]` | Teach Claude Code and Codex to use Shipyard | -| `berthd secret test REF` | Check this box can resolve `op://vault/item/field` or `env://NAME` (prints the value's length, never the value) | -| `berthd secret exec [--socket PATH] -- PROGRAM [ARGS...]` | Used by sessions and services: resolves secret references, then runs the program. See [secrets](/guides/secrets#how-values-are-handled). | +| `berthd secret test REF` | Check this box can resolve `op://vault/item/field`, `env://NAME` or `box://NAME` (prints the value's length, never the value) | +| `berthd secret request NAME --reason WHY [--new] [--store local\|gcp] [--environment staging\|production] [--grant SA_EMAIL] [--ttl 1h\|24h\|7d] [--wait 90s]` | From an agent session: ask the person for a secret through a card in Shipyard. Returns at once; the answer comes as a `` with a reference. See [secrets](/guides/secrets#when-an-agent-asks-for-a-secret). | +| `berthd secret exec [--socket PATH] [--env VAR=REF]... [--file VAR=REF]... -- PROGRAM [ARGS...]` | Used by sessions and services: resolves secret references, then runs the program. `--env` and `--file` hand a saved secret to one command, as a variable or a temporary file. See [secrets](/guides/secrets#using-a-saved-secret). | | `berthd secret signin [--check]` | Signs the box's `op` in to 1Password in this terminal (`op signin`) and keeps its session for berthd's `op://` reads; a team setup's 1Password step runs it. `--check` asks nothing and exits 0 when berthd can read 1Password now. See [secrets](/guides/secrets#setting-up-1password-on-a-box). | | `berthd emit TYPE [key=value...] [--origin TOOL]` | Announce an event, e.g. `deploy.finished` | | `berthd events [--json]` | Stream the box's events | diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 302ea2ed9..beea72752 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -205,7 +205,7 @@ See [orchestration](/guides/orchestration) and | Command | What it does | | --- | --- | | `berth skills BOX [list\|install\|uninstall] [SKILL...] [--agent claude\|codex\|all] [--target user\|project] [--location LOC] [--commit]` | Teach Claude Code and Codex on a box to use Shipyard | -| `berth secret test BOX REF` | Check a box can resolve `op://vault/item/field` or `env://NAME` (prints the value's length, never the value) | +| `berth secret test BOX REF` | Check a box can resolve `op://vault/item/field`, `env://NAME` or `box://NAME` (prints the value's length, never the value) | | `berth emit TYPE [key=value...]` | Announce an event on this laptop | | `berth emit BOX TYPE [key=value...] [--origin TOOL]` | Announce an event on a box | diff --git a/docs/reference/events.mdx b/docs/reference/events.mdx index def6c26af..485227395 100644 --- a/docs/reference/events.mdx +++ b/docs/reference/events.mdx @@ -138,6 +138,10 @@ events. | `integrations.installed` | `tool`, `account?`: an agent CLI's hooks and skills were installed from the app or the API, or by berthd in an account folder a new session uses | | `agents.installed` | `agents`: agent CLIs were added to the box from the app (**Add agents…**) or `POST /v1/agents/install` | | `secret.failed` | `location`, `name`, `path`, `variable`, `ref`, `reason`: a [secret reference](/guides/secrets) could not be read, so the variable was left unset; never the value | +| `secret.requested` | `id`, `session`, `name`, `location`, `reason`: an agent asked for a [secret](/guides/secrets#when-an-agent-asks-for-a-secret) | +| `secret.request.ended` | `id`, `session`, `name`, `status` (`saved`, `declined`, `timed_out`) | +| `secret.saved`, `secret.deleted` | `name`, `store`, `where` (`expired: true` when the sweep deleted it); never the value | +| `secret.settings.changed` | Where secrets are saved changed | | `secret.resolved` | `location`, `name`, `path`, `variable`, `ref`: one that failed reads again | | `share.started`, `share.stopped` | `id`, `port`, `url` | | `unit.started`, `unit.stopped`, `unit.restarted` | `name` | diff --git a/go.mod b/go.mod index 10d884258..79e256550 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.27.0 require ( github.com/jackc/pgx/v5 v5.11.0 go.opentelemetry.io/proto/otlp v1.11.0 + golang.org/x/crypto v0.57.0 google.golang.org/protobuf v1.36.11 modernc.org/sqlite v1.60.1 tailscale.com v1.102.3 @@ -49,7 +50,6 @@ require ( github.com/x448/float16 v0.8.4 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/internal/box/api.go b/internal/box/api.go index 2efa67ec8..f64daf9a2 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -94,6 +94,9 @@ type Box struct { // Secrets resolves secret references in worktree environments; nil // uses a shared one. Secrets *Secrets + // SecretRequests keeps agents' asks for a secret (secretrequests.go); + // nil on a box without them. + SecretRequests *SecretRequests // Socket is the box's local API socket, which programs berthd starts can // report back through. Socket string @@ -203,6 +206,7 @@ func (b *Box) Mount(s *wire.Server) { route("PUT /v1/env", b.putBoxEnv) route("POST /v1/secrets/test", b.testSecret) route("POST /v1/secrets/report", b.reportSecrets) + b.mountSecrets(route) route("GET /v1/locations/{name}/config", b.getConfig) route("PUT /v1/locations/{name}/config", b.putConfig) route("POST /v1/locations/{name}/config/trust", b.trustRepoConfig) diff --git a/internal/box/client.go b/internal/box/client.go index a421f0a7d..94fb6cc79 100644 --- a/internal/box/client.go +++ b/internal/box/client.go @@ -376,6 +376,18 @@ func (c *Client) ReportSecrets(ctx context.Context, r SecretReport) error { return c.call(ctx, http.MethodPost, "/v1/secrets/report", r, nil) } +// RequestSecret asks the person for a secret, for the caller's session: +// the box gives a saved one's reference at once, or a pending request. +func (c *Client) RequestSecret(ctx context.Context, req SecretRequestBody) (out SecretRequest, err error) { + return out, c.call(ctx, http.MethodPost, "/v1/secrets/requests", req, &out) +} + +// SecretRequest is one request as it stands: pending, saved (with its +// reference), declined or timed out. +func (c *Client) SecretRequest(ctx context.Context, id string) (out SecretRequest, err error) { + return out, c.call(ctx, http.MethodGet, "/v1/secrets/requests/"+url.PathEscape(id), nil, &out) +} + func (c *Client) Emit(ctx context.Context, typ string, data map[string]any) error { return c.call(ctx, http.MethodPost, "/v1/events", map[string]any{"type": typ, "data": data}, nil) } diff --git a/internal/box/main_test.go b/internal/box/main_test.go index 996456d55..d5c9d992b 100644 --- a/internal/box/main_test.go +++ b/internal/box/main_test.go @@ -23,6 +23,10 @@ func TestMain(m *testing.M) { } os.Exit(code) } + // The secret stores' programs, faked (secretstore_test.go). + if os.Getenv("BERTH_TEST_FAKE") != "" && os.Getenv("BERTH_TEST_FAKE_DIR") != "" { + os.Exit(fakeProgram()) + } for k, v := range map[string]string{ "GIT_AUTHOR_NAME": "berth test", "GIT_AUTHOR_EMAIL": "test@example.com", "GIT_COMMITTER_NAME": "berth test", "GIT_COMMITTER_EMAIL": "test@example.com", diff --git a/internal/box/notify.go b/internal/box/notify.go index 4fb9ace48..de3ec0a71 100644 --- a/internal/box/notify.go +++ b/internal/box/notify.go @@ -94,6 +94,8 @@ type Report struct { Needs string `json:"needs,omitempty"` Error string `json:"error,omitempty"` Transcript string `json:"transcript,omitempty"` + // Ref is a saved secret's reference (kind secret). + Ref string `json:"ref,omitempty"` } // Limits. A parent hears at most once every MinGap and PerHour times an @@ -228,6 +230,20 @@ func (n *Notifier) Kick() { } } +// Tell queues a report that is not read off a watch (a secret request's +// answer), to be said once at the parent's next idle. +func (n *Notifier) Tell(r Report) { + n.mu.Lock() + n.load() + if r.Key != "" && r.Parent != "" && !n.done(r.Key) && !n.pendingKey(r.Key) { + n.st.Pending[r.Parent] = append(n.st.Pending[r.Parent], r) + n.dirty = true + n.save() + } + n.mu.Unlock() + n.Kick() +} + // Add records a watch. One already kept (the same work for the same // parent) is kept as it is. A watch that would make a cycle is refused. func (n *Notifier) Add(w Watch) error { diff --git a/internal/box/notify_text.go b/internal/box/notify_text.go index d53fa68e7..56d80ff52 100644 --- a/internal/box/notify_text.go +++ b/internal/box/notify_text.go @@ -53,6 +53,9 @@ func writeReport(b *strings.Builder, r Report) { attrs = append(attrs, [2]string{"title", r.Title}) } } + if r.Kind == "secret" { + attrs = append(attrs, [2]string{"title", r.Title}, [2]string{"ref", r.Ref}) + } if r.Worktree != "" { attrs = append(attrs, [2]string{"worktree", r.Worktree}) } @@ -99,6 +102,9 @@ func writeReport(b *strings.Builder, r Report) { // reportSummary is the report in one line. func reportSummary(r Report) string { + if r.Kind == "secret" { + return secretSummary(r) + } who := firstNonEmpty(r.Worktree, r.Session) if r.Kind == "run" { who = "run " + r.Run + " (" + firstNonEmpty(r.Title, r.Template) + ")" @@ -132,6 +138,20 @@ func reportSummary(r Report) string { return who + ": " + r.Status + "." } +// secretSummary is what came of a secret request, for the agent that made +// it: a reference to use, never the value. +func secretSummary(r Report) string { + switch r.Status { + case secretSaved: + return fmt.Sprintf("The person saved the secret %s. Its reference is %s: run a command with it as `berthd secret exec --env %s=%s -- COMMAND`, or `--file %s=%s` for a file holding it. Never print the value: a command that prints it puts it in the chat.", r.Title, r.Ref, r.Title, r.Ref, r.Title, r.Ref) + case secretDeclined: + return fmt.Sprintf("The person declined to give the secret %s. Carry on without it, or ask them what to do.", r.Title) + case secretTimedOut: + return fmt.Sprintf("Nobody answered the request for the secret %s within an hour. Ask again with berthd secret request if it is still needed, and tell the user.", r.Title) + } + return "The request for the secret " + r.Title + ": " + r.Status + "." +} + func changesText(r Report) string { if len(r.Files) == 0 { return "" diff --git a/internal/box/repoconfig.go b/internal/box/repoconfig.go index c6bc241ad..da2c1d019 100644 --- a/internal/box/repoconfig.go +++ b/internal/box/repoconfig.go @@ -71,6 +71,11 @@ func (c RepoConfig) validate() error { if err := validateEnvRefs(c.Env); err != nil { return err } + if c.Secrets != nil { + if err := c.Secrets.validate(); err != nil { + return err + } + } if err := ValidateFlows(c.Flows); err != nil { return err } @@ -130,6 +135,8 @@ func merge(repo, local RepoConfig) RepoConfig { if local.KeepMain != nil { out.KeepMain = local.KeepMain } + // Where secrets go is the box's to say alone. + out.Secrets = local.Secrets return out } diff --git a/internal/box/scripts.go b/internal/box/scripts.go index f9afc7cbe..ac5287752 100644 --- a/internal/box/scripts.go +++ b/internal/box/scripts.go @@ -69,6 +69,10 @@ type RepoConfig struct { // fetches it first and fast-forwards the local one (freshbase.go). On // unless a layer says false. KeepMain *bool `json:"keep_main,omitempty"` + // Secrets is where this project's secret requests save, over the + // box's default (secretstore.go). Only the box's own config sets it, + // never a repository's file. + Secrets *SecretStoreChoice `json:"secrets,omitempty"` } // ReadRepoConfig reads repo's .berth/config.json; ok is false without one. diff --git a/internal/box/secretrequests.go b/internal/box/secretrequests.go new file mode 100644 index 000000000..fad644a85 --- /dev/null +++ b/internal/box/secretrequests.go @@ -0,0 +1,760 @@ +package box + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/cosscom/shipyard/internal/events" + "github.com/cosscom/shipyard/internal/statefile" + "github.com/cosscom/shipyard/internal/wire" +) + +// Secret requests: an agent that needs a key asks for it by name (`berthd +// secret request NAME --reason …`, or the berth_secret_request tool), and +// the person types it into a masked card in the app. The value goes from +// the app to the box in one direct call (POST +// /v1/secrets/requests/{id}/save), which the box's own socket can't make, +// into a store (secretstore.go); the agent hears back with a +// holding a reference, never the value. While a +// request waits its session reads as needing someone, wherever a question +// shows. +// +// A request names nothing secret, so it is kept, announced and listed like +// anything else: the name, the reason, the store. The value is never in a +// request, an event, a log, a hook's data or a reply. + +// secretRequestTTL is how long a request waits for an answer. +const secretRequestTTL = time.Hour + +// Request outcomes. +const ( + secretPending = "pending" + secretSaved = "saved" + secretDeclined = "declined" + secretTimedOut = "timed_out" +) + +// secretTTLs are the lifetimes a card offers besides none. +var secretTTLs = map[string]time.Duration{"1h": time.Hour, "24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour} + +// SecretRequest is one ask for a secret, as the card shows it. +type SecretRequest struct { + ID string `json:"id"` + Name string `json:"name"` + Reason string `json:"reason"` + Session string `json:"session"` + // Location is the session's project. + Location string `json:"location,omitempty"` + // Store is the provider it saves to; StoreLabel where the value goes + // ("Keychain", "GCP Secret Manager"), Where the place in it (a GCP + // project). + Store string `json:"store"` + StoreLabel string `json:"store_label"` + Where string `json:"where,omitempty"` + Environment string `json:"environment,omitempty"` + Grant string `json:"grant,omitempty"` + // TTL is the lifetime the agent suggests; the card preselects it. + TTL string `json:"ttl,omitempty"` + // Replace is a request for a new value over one saved before, at + // ReplacedAt. + Replace bool `json:"replace,omitempty"` + ReplacedAt *time.Time `json:"replaced_at,omitempty"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` + Status string `json:"status"` + // Ref is what a saved request gives the agent; Reused says it was + // saved before, and no card was shown. + Ref string `json:"ref,omitempty"` + Reused bool `json:"reused,omitempty"` + Ended *time.Time `json:"ended,omitempty"` +} + +// SecretRequests keeps the requests, pending and lately ended, in one +// private file, so a restart loses none. +type SecretRequests struct { + Path string + // Now is the clock; nil is time.Now. + Now func() time.Time + + mu sync.Mutex + loaded bool + all []*SecretRequest +} + +const keepEndedRequests = 24 * time.Hour + +func (s *SecretRequests) now() time.Time { + if s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} + +func (s *SecretRequests) load() { + if s.loaded { + return + } + s.loaded = true + if s.Path == "" { + return + } + if b, err := os.ReadFile(s.Path); err == nil { + json.Unmarshal(b, &s.all) + } +} + +func (s *SecretRequests) save() { + if s.Path == "" { + return + } + now := s.now() + keep := s.all[:0] + for _, r := range s.all { + if r.Ended == nil || now.Sub(*r.Ended) < keepEndedRequests { + keep = append(keep, r) + } + } + s.all = keep + if b, err := json.Marshal(s.all); err == nil { + statefile.Write(s.Path, b) + } +} + +func newSecretRequestID() string { + b := make([]byte, 8) + rand.Read(b) + return "sr_" + hex.EncodeToString(b) +} + +// add keeps a new request, or gives back the one already pending for the +// same session and name (made false). +func (s *SecretRequests) add(r SecretRequest) (_ SecretRequest, made bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.load() + for _, o := range s.all { + if o.Status == secretPending && o.Session == r.Session && o.Name == r.Name { + return *o, false + } + } + now := s.now() + r.ID, r.Created, r.Expires, r.Status = newSecretRequestID(), now, now.Add(secretRequestTTL), secretPending + s.all = append(s.all, &r) + s.save() + return r, true +} + +// Get is one request. +func (s *SecretRequests) Get(id string) (SecretRequest, bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.load() + for _, r := range s.all { + if r.ID == id { + return *r, true + } + } + return SecretRequest{}, false +} + +// Pending are the requests that wait for an answer, oldest first. +func (s *SecretRequests) Pending() []SecretRequest { + s.mu.Lock() + defer s.mu.Unlock() + s.load() + out := []SecretRequest{} + for _, r := range s.all { + if r.Status == secretPending { + out = append(out, *r) + } + } + return out +} + +// ForSession is the request a session waits on, if any. +func (s *SecretRequests) ForSession(name string) *SecretRequest { + for _, r := range s.Pending() { + if r.Session == name { + return &r + } + } + return nil +} + +// end settles a pending request; false when it was settled already. +func (s *SecretRequests) end(id, status, ref string) (SecretRequest, bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.load() + for _, r := range s.all { + if r.ID == id { + if r.Status != secretPending { + return *r, false + } + now := s.now() + r.Status, r.Ref, r.Ended = status, ref, &now + s.save() + return *r, true + } + } + return SecretRequest{}, false +} + +// expired are the pending requests whose hour is up. +func (s *SecretRequests) expired() []string { + now := s.now() + var ids []string + for _, r := range s.Pending() { + if !now.Before(r.Expires) { + ids = append(ids, r.ID) + } + } + return ids +} + +// The secrets API. Requests are made from the box's own socket, by an +// agent session (the caller), and answered from a paired laptop only. +func (b *Box) mountSecrets(route func(string, func(http.ResponseWriter, *http.Request) error)) { + route("POST /v1/secrets/requests", b.createSecretRequest) + route("GET /v1/secrets/requests", b.listSecretRequests) + route("GET /v1/secrets/requests/{id}", b.getSecretRequest) + route("POST /v1/secrets/requests/{id}/save", b.saveSecretRequest) + route("POST /v1/secrets/requests/{id}/decline", b.declineSecretRequest) + route("GET /v1/secrets", b.listSavedSecrets) + route("POST /v1/secrets/delete", b.deleteSavedSecret) + route("GET /v1/secrets/settings", b.getSecretSettings) + route("PUT /v1/secrets/settings", b.putSecretSettings) +} + +// fromLaptop refuses what only a person, in the app, may do. +func fromLaptop(r *http.Request, what string) error { + if wire.IsLocal(r.Context()) { + return httpError{http.StatusForbidden, what + " from the Shipyard app on a paired laptop, not from the box's own socket"} + } + return nil +} + +func (b *Box) secretStores() (*SecretStores, error) { + if b.SecretRequests == nil || b.secrets().Stores == nil { + return nil, httpError{http.StatusNotImplemented, "this box keeps no secrets"} + } + return b.secrets().Stores, nil +} + +// SecretRequestBody is what an agent asks with. +type SecretRequestBody struct { + Name string `json:"name"` + Reason string `json:"reason"` + // New asks for a value even when one is saved: a replacement. + New bool `json:"new,omitempty"` + Store string `json:"store,omitempty"` + Environment string `json:"environment,omitempty"` + Grant string `json:"grant,omitempty"` + TTL string `json:"ttl,omitempty"` +} + +// projectChoice is a location's own choice of store, from its box-local +// config. +func (b *Box) projectChoice(location string) SecretStoreChoice { + if location == "" || b.Locations == nil { + return SecretStoreChoice{} + } + saved, err := b.Locations.saved(location) + if err != nil || saved.Config == nil || saved.Config.Secrets == nil { + return SecretStoreChoice{} + } + return *saved.Config.Secrets +} + +func (b *Box) createSecretRequest(w http.ResponseWriter, r *http.Request) error { + if !wire.IsLocal(r.Context()) { + return httpError{http.StatusForbidden, "secrets are requested by agents on the box, through its own socket"} + } + stores, err := b.secretStores() + if err != nil { + return err + } + caller := strings.TrimSpace(r.Header.Get(CallerHeader)) + if caller == "" || !sessionName.MatchString(caller) { + return badRequest("a secret is requested by an agent session: run it in one (BERTH_SESSION names it)") + } + sess, err := b.Sessions.Get(r.Context(), caller) + if err != nil || sess.Exited || agentFor(sess) == "" { + return badRequest("%s is not an agent session on this box", caller) + } + var req SecretRequestBody + if err := decode(r, &req); err != nil { + return err + } + req.Name, req.Reason = strings.TrimSpace(req.Name), strings.TrimSpace(req.Reason) + switch { + case !secretName.MatchString(req.Name): + return badRequest("%q is not a secret name: use an environment variable's, like ACME_TOKEN", req.Name) + case req.Reason == "": + return badRequest("say why the secret is needed (--reason): the person reads it on the card") + case utf8.RuneCountInString(req.Reason) > 500: + return badRequest("the reason is longer than 500 characters") + case req.Environment != "" && req.Environment != "staging" && req.Environment != "production": + return badRequest("the environment is staging or production") + } + if req.TTL != "" { + if _, ok := secretTTLs[req.TTL]; !ok { + return badRequest("the ttl is 1h, 24h or 7d (or none: kept until deleted)") + } + if req.Environment == "production" { + return badRequest("a production secret doesn't expire: leave out the ttl") + } + } + id, where := Choose(req.Store, b.projectChoice(sess.Location), stores.BoxSettings()) + st, err := stores.Open(id, where) + if err != nil { + return badRequest("%v", err) + } + if req.Grant != "" { + if !secretProviderByID(id).Caps.Grant { + return badRequest("the %s store can't grant access: --grant is for gcp", id) + } + if err := ValidateServiceAccount(req.Grant); err != nil { + return badRequest("%v", err) + } + } + // One saved and unexpired is given at once, with no card. + var prev *SecretMeta + now := time.Now() + for _, m := range stores.Index().All() { + if m.Name == req.Name && m.Store == id && m.Where == where && !m.expired(now) { + m := m + prev = &m + } + } + if prev != nil && !req.New { + writeJSON(w, SecretRequest{Name: req.Name, Session: caller, Store: id, StoreLabel: prev.StoreLabel, Where: where, Status: secretSaved, Ref: prev.Ref, Reused: true}) + return nil + } + sr := SecretRequest{Name: req.Name, Reason: req.Reason, Session: caller, Location: sess.Location, Store: id, StoreLabel: st.Label(), Where: where, Environment: req.Environment, Grant: req.Grant, TTL: req.TTL, Replace: req.New && prev != nil} + if sr.Replace { + created := prev.Created + sr.ReplacedAt = &created + } + sr, made := b.SecretRequests.add(sr) + if made { + b.publish(r, "secret.requested", map[string]any{"id": sr.ID, "session": sr.Session, "name": sr.Name, "location": sr.Location, "reason": sr.Reason}) + } + writeJSON(w, sr) + return nil +} + +func (b *Box) listSecretRequests(w http.ResponseWriter, r *http.Request) error { + if b.SecretRequests == nil { + writeJSON(w, []SecretRequest{}) + return nil + } + writeJSON(w, b.SecretRequests.Pending()) + return nil +} + +func (b *Box) getSecretRequest(w http.ResponseWriter, r *http.Request) error { + if b.SecretRequests == nil { + return httpError{http.StatusNotImplemented, "this box keeps no secrets"} + } + sr, ok := b.SecretRequests.Get(r.PathValue("id")) + if !ok { + return httpError{http.StatusNotFound, "no such secret request"} + } + writeJSON(w, sr) + return nil +} + +// maxSecretValue bounds a saved value: a key, a token, a small JSON +// credential. +const maxSecretValue = 32 << 10 + +func (b *Box) saveSecretRequest(w http.ResponseWriter, r *http.Request) error { + if err := fromLaptop(r, "a secret is saved"); err != nil { + return err + } + stores, err := b.secretStores() + if err != nil { + return err + } + var req struct { + Value string `json:"value"` + Expires string `json:"expires"` + } + if err := decodeLimit(r, &req, maxSecretValue+4<<10); err != nil { + return err + } + // Not echoed, ever: errors say what is wrong with it, not what it is. + value := strings.TrimRight(req.Value, "\r\n") + switch { + case value == "": + return badRequest("type or paste the value first") + case len(value) > maxSecretValue: + return badRequest("the value is longer than %d KB", maxSecretValue>>10) + case strings.ContainsRune(value, 0): + return badRequest("the value holds a NUL character; copy it again") + } + sr, ok := b.SecretRequests.Get(r.PathValue("id")) + if !ok { + return httpError{http.StatusNotFound, "no such secret request"} + } + if sr.Status != secretPending { + return httpError{http.StatusConflict, "this request is " + strings.ReplaceAll(sr.Status, "_", " ") + " already"} + } + var expires *time.Time + if req.Expires != "" && sr.Environment != "production" { + d, ok := secretTTLs[req.Expires] + if !ok { + return badRequest("expires is 1h, 24h, 7d or none") + } + if !secretProviderByID(sr.Store).Caps.Expiry { + return badRequest("the %s store keeps secrets until they're deleted", sr.Store) + } + t := time.Now().UTC().Add(d) + expires = &t + } + st, err := stores.Open(sr.Store, sr.Where) + if err != nil { + return badRequest("%v", err) + } + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + ref, err := st.Save(ctx, SecretSave{Name: sr.Name, Value: value, Environment: sr.Environment, Location: sr.Location, Grant: sr.Grant}) + value, req.Value = "", "" + if err != nil { + return httpError{http.StatusBadGateway, err.Error()} + } + meta := SecretMeta{Name: sr.Name, Ref: ref, Store: sr.Store, StoreLabel: st.Label(), Where: sr.Where, Environment: sr.Environment, Created: time.Now().UTC(), Expires: expires, Session: sr.Session, Reason: sr.Reason, Location: sr.Location} + if d, ok := st.(interface{ Detail(string) (string, string) }); ok { + if backend, label := d.Detail(ref); label != "" { + meta.Backend, meta.StoreLabel = backend, label + } + } + if err := stores.Index().Put(meta); err != nil { + return err + } + b.secrets().Forget(ref) + b.endSecretRequest(r, sr.ID, secretSaved, ref) + b.publish(r, "secret.saved", map[string]any{"name": sr.Name, "store": sr.Store, "where": sr.Where}) + writeJSON(w, map[string]string{"ref": ref}) + return nil +} + +func (b *Box) declineSecretRequest(w http.ResponseWriter, r *http.Request) error { + if err := fromLaptop(r, "a secret request is declined"); err != nil { + return err + } + if b.SecretRequests == nil { + return httpError{http.StatusNotImplemented, "this box keeps no secrets"} + } + sr, ok := b.SecretRequests.Get(r.PathValue("id")) + if !ok { + return httpError{http.StatusNotFound, "no such secret request"} + } + if sr.Status != secretPending { + return httpError{http.StatusConflict, "this request is " + strings.ReplaceAll(sr.Status, "_", " ") + " already"} + } + b.endSecretRequest(r, sr.ID, secretDeclined, "") + writeJSON(w, map[string]bool{"ok": true}) + return nil +} + +// endSecretRequest settles a request, says so, and tells its session. +func (b *Box) endSecretRequest(r *http.Request, id, status, ref string) { + sr, ok := b.SecretRequests.end(id, status, ref) + if !ok { + return + } + data := map[string]any{"id": sr.ID, "session": sr.Session, "name": sr.Name, "status": status} + if b.Events != nil { + e := events.Event{Type: "secret.request.ended", Box: b.Name, Data: data} + if r != nil { + e.Origin = origin(r) + } + b.Events.Publish(e) + } + if b.Reports != nil { + b.Reports.Tell(Report{Key: "secret:" + sr.ID, Parent: sr.Session, Kind: "secret", At: time.Now().UTC(), Status: status, Title: sr.Name, Ref: ref, Session: ""}) + } +} + +// SweepSecrets times out the requests nobody answered and deletes the +// saved secrets whose time is up. +func (b *Box) SweepSecrets(ctx context.Context) { + if b.SecretRequests == nil { + return + } + for _, id := range b.SecretRequests.expired() { + b.endSecretRequest(nil, id, secretTimedOut, "") + } + if st := b.secrets().Stores; st != nil { + for _, name := range st.Sweep(ctx) { + if b.Events != nil { + b.Events.Publish(events.Event{Type: "secret.deleted", Box: b.Name, Data: map[string]any{"name": name, "expired": true}}) + } + } + } +} + +// RunSecretSweeps sweeps every minute until ctx ends. +func (b *Box) RunSecretSweeps(ctx context.Context) { + t := time.NewTicker(time.Minute) + defer t.Stop() + for { + b.SweepSecrets(ctx) + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// StoreInfo is one provider, as Settings offers it. +type StoreInfo struct { + ID string `json:"id"` + Label string `json:"label"` + Caps StoreCaps `json:"caps"` +} + +// SavedSecrets is what Settings → Secrets lists. +type SavedSecrets struct { + Secrets []SecretMeta `json:"secrets"` + Stores []StoreInfo `json:"stores"` + Settings SecretSettings `json:"settings"` + Errors []SecretStoreErr `json:"errors,omitempty"` +} + +// SecretStoreErr is a store that couldn't be listed. +type SecretStoreErr struct { + Store string `json:"store"` + Where string `json:"where,omitempty"` + Error string `json:"error"` +} + +func (b *Box) secretSettings() SecretSettings { + stores := b.secrets().Stores + def := stores.BoxSettings() + out := SecretSettings{Store: def.Store, GCPProject: def.GCPProject, Locations: map[string]SecretStoreChoice{}} + if b.Locations != nil { + if all, err := b.Locations.read(); err == nil { + for _, l := range all { + if l.Config != nil && l.Config.Secrets != nil { + out.Locations[l.Name] = *l.Config.Secrets + } + } + } + } + return out +} + +// storePlaces are the stores and places Settings lists: every provider at +// each place the box or a project names for it. +func storePlaces(set SecretSettings) [][2]string { + seen := map[[2]string]bool{} + var out [][2]string + add := func(id string, c SecretStoreChoice) { + p := secretProviderByID(id) + if p == nil { + return + } + where := c.whereFor(id) + if p.NeedsWhere && where == "" { + return + } + k := [2]string{id, where} + if !seen[k] { + seen[k] = true + out = append(out, k) + } + } + boxDefault := SecretStoreChoice{Store: set.Store, GCPProject: set.GCPProject} + for _, id := range secretProviderIDs() { + add(id, boxDefault) + names := make([]string, 0, len(set.Locations)) + for n := range set.Locations { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + c := set.Locations[n] + if c.GCPProject == "" { + c.GCPProject = set.GCPProject + } + add(id, c) + } + } + return out +} + +func (b *Box) listSavedSecrets(w http.ResponseWriter, r *http.Request) error { + stores, err := b.secretStores() + if err != nil { + return err + } + set := b.secretSettings() + out := SavedSecrets{Secrets: []SecretMeta{}, Settings: set} + for _, id := range secretProviderIDs() { + p := secretProviderByID(id) + out.Stores = append(out.Stores, StoreInfo{ID: id, Label: p.Name, Caps: p.Caps}) + } + index := map[string]SecretMeta{} + for _, m := range stores.Index().All() { + index[m.Ref] = m + } + listed := map[string]bool{} + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + for _, pl := range storePlaces(set) { + st, err := stores.Open(pl[0], pl[1]) + var metas []SecretMeta + if err == nil { + metas, err = st.List(ctx) + } + if err != nil { + out.Errors = append(out.Errors, SecretStoreErr{Store: pl[0], Where: pl[1], Error: err.Error()}) + // What the box saved there still shows, from its own record. + for _, m := range index { + if m.Store == pl[0] && m.Where == pl[1] && !listed[m.Ref] { + listed[m.Ref] = true + out.Secrets = append(out.Secrets, m) + } + } + continue + } + for _, m := range metas { + if listed[m.Ref] { + continue + } + listed[m.Ref] = true + if x, ok := index[m.Ref]; ok { + x.StoreLabel = firstNonEmptyStr(m.StoreLabel, x.StoreLabel) + m = x + } + out.Secrets = append(out.Secrets, m) + } + } + sort.Slice(out.Secrets, func(i, j int) bool { + if out.Secrets[i].Name != out.Secrets[j].Name { + return out.Secrets[i].Name < out.Secrets[j].Name + } + return out.Secrets[i].Ref < out.Secrets[j].Ref + }) + writeJSON(w, out) + return nil +} + +func (b *Box) deleteSavedSecret(w http.ResponseWriter, r *http.Request) error { + if err := fromLaptop(r, "a secret is deleted"); err != nil { + return err + } + stores, err := b.secretStores() + if err != nil { + return err + } + var req struct { + Ref string `json:"ref"` + Confirm string `json:"confirm"` + } + if err := decode(r, &req); err != nil { + return err + } + st, err := stores.ForRef(req.Ref) + if err != nil { + return badRequest("%v", err) + } + meta, known := stores.Index().Get(req.Ref) + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + if !known { + metas, err := st.List(ctx) + if err != nil { + return httpError{http.StatusBadGateway, err.Error()} + } + for _, m := range metas { + if m.Ref == req.Ref { + meta, known = m, true + } + } + } + if !known { + return httpError{http.StatusNotFound, "no such saved secret"} + } + if meta.Environment == "production" && req.Confirm != meta.Name { + return badRequest("%s is a production secret: type its name to delete it", meta.Name) + } + if err := st.Delete(ctx, req.Ref); err != nil && !errors.Is(err, errSecretNotFound) { + return httpError{http.StatusBadGateway, err.Error()} + } + if err := stores.Index().Remove(req.Ref); err != nil { + return err + } + b.secrets().Forget(req.Ref) + b.publish(r, "secret.deleted", map[string]any{"name": meta.Name, "store": meta.Store, "where": meta.Where}) + writeJSON(w, map[string]bool{"ok": true}) + return nil +} + +func (b *Box) getSecretSettings(w http.ResponseWriter, r *http.Request) error { + if _, err := b.secretStores(); err != nil { + return err + } + writeJSON(w, b.secretSettings()) + return nil +} + +func (b *Box) putSecretSettings(w http.ResponseWriter, r *http.Request) error { + if err := fromLaptop(r, "where secrets are saved is set"); err != nil { + return err + } + stores, err := b.secretStores() + if err != nil { + return err + } + var req SecretSettings + if err := decode(r, &req); err != nil { + return err + } + boxChoice := SecretStoreChoice{Store: firstNonEmptyStr(req.Store, "local"), GCPProject: strings.TrimSpace(req.GCPProject)} + if err := boxChoice.validate(); err != nil { + return badRequest("%v", err) + } + for name, c := range req.Locations { + if err := c.validate(); err != nil { + return badRequest("%s: %v", name, err) + } + } + if err := stores.SetBoxSettings(boxChoice); err != nil { + return err + } + for name, c := range req.Locations { + cfg, err := b.Locations.Config(r.Context(), name) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + local := cfg.Local + if c.Store == "" && c.GCPProject == "" { + local.Secrets = nil + } else { + cc := c + local.Secrets = &cc + } + if err := b.Locations.SetLocalConfig(name, local); err != nil { + return badRequest("%s: %v", name, err) + } + } + b.publish(r, "secret.settings.changed", map[string]any{}) + writeJSON(w, b.secretSettings()) + return nil +} diff --git a/internal/box/secretrequests_test.go b/internal/box/secretrequests_test.go new file mode 100644 index 000000000..1315651a0 --- /dev/null +++ b/internal/box/secretrequests_test.go @@ -0,0 +1,474 @@ +package box + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/events" + "github.com/cosscom/shipyard/internal/wire" +) + +// secretBed is a box with an agent session, lead, in location acme, served +// to a paired laptop (remote) and on its own socket (local, as lead). +type secretBed struct { + bx *Box + remote *wire.Client + local *Client + state string + clock *time.Time + mu sync.Mutex + seen []events.Event +} + +func newSecretBed(t *testing.T, bins map[string]string) *secretBed { + t.Helper() + sb := &secretBed{state: t.TempDir()} + now := time.Now().UTC() + sb.clock = &now + ledger := &Turns{} + c, bus := servedBox(t, func(b *Box) { + b.Turns = ledger + b.Reports = &Notifier{Path: filepath.Join(sb.state, "notify.json")} + b.SecretRequests = &SecretRequests{Path: filepath.Join(sb.state, "secret-requests.json"), Now: func() time.Time { return *sb.clock }} + b.Secrets = &Secrets{Stores: &SecretStores{Dir: sb.state, Box: "devbox", Bins: bins}} + sb.bx = b + }) + ledger.Attach(bus) + sb.remote = c + stop := bus.Observe(func(e events.Event) { + sb.mu.Lock() + sb.seen = append(sb.seen, e) + sb.mu.Unlock() + }) + t.Cleanup(stop) + repo := gitRepo(t) + call(t, c, "POST", "/v1/locations", "", map[string]string{"name": "acme", "path": repo}, nil) + bin := t.TempDir() + fake := filepath.Join(bin, "claude") + os.WriteFile(fake, []byte("#!/bin/sh\nexec cat\n"), 0o755) + for _, name := range []string{"lead", "shell"} { + cmd := fake + if name == "shell" { + cmd = "sleep 300" + } + if status := call(t, c, "POST", "/v1/sessions", "", map[string]string{"location": "acme", "name": name, "command": cmd}, nil); status != 200 { + t.Fatalf("start %s: %d", name, status) + } + t.Cleanup(func() { sb.bx.Sessions.Kill(context.Background(), name) }) + } + sockDir, err := os.MkdirTemp("/tmp", "bsr") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(sockDir) }) + sock := filepath.Join(sockDir, "berthd.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + srv := &wire.Server{} + sb.bx.Mount(srv) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go srv.ServeLocal(ctx, ln) + sb.local = NewClient(NewLocal(sock)).WithCaller("lead") + return sb +} + +func (sb *secretBed) events() string { + sb.mu.Lock() + defer sb.mu.Unlock() + b, _ := json.Marshal(sb.seen) + return string(b) +} + +func (sb *secretBed) eventTypes() []string { + sb.mu.Lock() + defer sb.mu.Unlock() + var out []string + for _, e := range sb.seen { + if strings.HasPrefix(e.Type, "secret.") { + out = append(out, e.Type) + } + } + return out +} + +func (sb *secretBed) pendingReports(parent string) []Report { + n := sb.bx.Reports + n.mu.Lock() + defer n.mu.Unlock() + n.load() + return append([]Report(nil), n.st.Pending[parent]...) +} + +func (sb *secretBed) session(t *testing.T, name string) Session { + t.Helper() + var all []Session + call(t, sb.remote, "GET", "/v1/sessions", "", nil, &all) + for _, s := range all { + if s.Name == name { + return s + } + } + t.Fatalf("no session %s", name) + return Session{} +} + +// grepState says which files under dir hold s. +func grepState(dir, s string) []string { + var hits []string + filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + if b, _ := os.ReadFile(p); strings.Contains(string(b), s) { + hits = append(hits, p) + } + } + return nil + }) + return hits +} + +func TestASecretIsAskedForOnTheBoxAndSavedFromTheLaptopOnly(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + t.Setenv("XDG_RUNTIME_DIR", "") + sb := newSecretBed(t, noKeyrings) + ctx := context.Background() + const sentinel = "acme-sentinel-c0ffee-91" + + // Requests come from the box's own socket, from an agent session. + if status := call(t, sb.remote, "POST", "/v1/secrets/requests", "", SecretRequestBody{Name: "ACME_TOKEN", Reason: "deploy"}, nil); status != 403 { + t.Fatalf("a laptop made a request: %d", status) + } + anon := NewClient(sb.local.Doer) + if _, err := anon.RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "deploy"}); err == nil { + t.Fatal("a request with no session was taken") + } + if _, err := sb.local.WithCaller("shell").RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "deploy"}); err == nil { + t.Fatal("a plain terminal made a request") + } + for _, bad := range []SecretRequestBody{{Name: "acme_token", Reason: "x"}, {Name: "ACME_TOKEN"}, {Name: "ACME_TOKEN", Reason: "x", TTL: "3d"}, {Name: "ACME_TOKEN", Reason: "x", Environment: "production", TTL: "1h"}, {Name: "ACME_TOKEN", Reason: "x", Grant: "ci@acme.iam.gserviceaccount.com"}} { + if _, err := sb.local.RequestSecret(ctx, bad); err == nil { + t.Errorf("%+v was taken", bad) + } + } + + sr, err := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "Deploy the acme \"staging\" app", TTL: "24h"}) + if err != nil { + t.Fatal(err) + } + if sr.Status != "pending" || sr.ID == "" || sr.Session != "lead" || sr.Location != "acme" || sr.Store != "local" || sr.StoreLabel != "Encrypted file" || sr.TTL != "24h" || sr.Replace { + t.Fatalf("request = %+v", sr) + } + // Asking again while it waits gives the same request. + if again, _ := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "again"}); again.ID != sr.ID { + t.Fatalf("a second ask made %s", again.ID) + } + // The session reads as needing someone, and still takes prompts. + s := sb.session(t, "lead") + if s.AgentState != "waiting" || !s.SoftWait || s.Ask == nil || s.Ask.Tool != "SecretRequest" || s.SecretRequest == nil || s.SecretRequest.ID != sr.ID || s.SecretRequest.Reason != `Deploy the acme "staging" app` { + t.Fatalf("session = %+v ask %+v request %+v", s.AgentState, s.Ask, s.SecretRequest) + } + var pending []SecretRequest + call(t, sb.remote, "GET", "/v1/secrets/requests", "", nil, &pending) + if len(pending) != 1 || pending[0].ID != sr.ID { + t.Fatalf("pending = %+v", pending) + } + + // The box's own socket can neither save, decline nor delete. + for _, path := range []string{"/v1/secrets/requests/" + sr.ID + "/save", "/v1/secrets/requests/" + sr.ID + "/decline", "/v1/secrets/delete"} { + err := sb.local.Call(ctx, http.MethodPost, path, map[string]string{"value": sentinel, "ref": "box://ACME_TOKEN"}, nil) + if err == nil || !strings.Contains(err.Error(), "paired laptop") { + t.Errorf("POST %s from the box: %v", path, err) + } + } + if err := sb.local.Call(ctx, http.MethodPut, "/v1/secrets/settings", SecretSettings{Store: "local"}, nil); err == nil { + t.Error("the box's own socket set where secrets go") + } + + // The laptop saves it. + var saved map[string]string + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": sentinel + "\n", "expires": "24h"}, &saved); status != 200 || saved["ref"] != "box://ACME_TOKEN" { + t.Fatalf("save: %d %v", status, saved) + } + if v, err := sb.bx.secrets().Resolve(ctx, "box://ACME_TOKEN", nil, true); err != nil || v != sentinel { + t.Fatalf("resolve: %q %v", v, err) + } + if again := call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": "x"}, nil); again != 409 { + t.Fatalf("a second save: %d", again) + } + got, _ := sb.local.SecretRequest(ctx, sr.ID) + if got.Status != "saved" || got.Ref != "box://ACME_TOKEN" { + t.Fatalf("after save: %+v", got) + } + if s := sb.session(t, "lead"); s.SecretRequest != nil || s.Ask != nil && s.Ask.Tool == "SecretRequest" { + t.Fatalf("still waiting: %+v", s.SecretRequest) + } + reps := sb.pendingReports("lead") + if len(reps) != 1 || reps[0].Kind != "secret" || reps[0].Status != "saved" || reps[0].Ref != "box://ACME_TOKEN" { + t.Fatalf("reports = %+v", reps) + } + text := NotificationText(reps, 0) + if !strings.Contains(text, "box://ACME_TOKEN") || !strings.Contains(text, "berthd secret exec --env ACME_TOKEN=box://ACME_TOKEN") || strings.Contains(text, sentinel) { + t.Fatalf("notification:\n%s", text) + } + var list SavedSecrets + call(t, sb.remote, "GET", "/v1/secrets", "", nil, &list) + if len(list.Secrets) != 1 || list.Secrets[0].Name != "ACME_TOKEN" || list.Secrets[0].Session != "lead" || list.Secrets[0].Reason == "" || list.Secrets[0].StoreLabel != "Encrypted file" || list.Secrets[0].Expires == nil { + t.Fatalf("list = %+v", list) + } + + // Saved and unexpired: given at once, with no card. + again, err := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "deploy again"}) + if err != nil || again.Status != "saved" || !again.Reused || again.Ref != "box://ACME_TOKEN" || again.ID != "" { + t.Fatalf("reuse: %+v %v", again, err) + } + if len(sb.bx.SecretRequests.Pending()) != 0 { + t.Fatal("reuse made a pending request") + } + // --new asks for a replacement, saying when the old one was saved. + repl, err := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "rotate", New: true}) + if err != nil || repl.Status != "pending" || !repl.Replace || repl.ReplacedAt == nil { + t.Fatalf("--new: %+v %v", repl, err) + } + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+repl.ID+"/decline", "app", nil, nil); status != 200 { + t.Fatalf("decline: %d", status) + } + if reps := sb.pendingReports("lead"); len(reps) != 2 || reps[1].Status != "declined" || !strings.Contains(NotificationText(reps[1:], 0), "declined") { + t.Fatalf("reports after decline = %+v", reps) + } + + // Deleting it from the laptop. + if status := call(t, sb.remote, "POST", "/v1/secrets/delete", "app", map[string]string{"ref": "box://ACME_TOKEN"}, nil); status != 200 { + t.Fatalf("delete: %d", status) + } + if _, err := sb.bx.secrets().Resolve(ctx, "box://ACME_TOKEN", nil, true); err == nil { + t.Fatal("a deleted secret resolved") + } + + // The value is nowhere but the encrypted store: no event, no state + // file, no reply. + if strings.Contains(sb.events(), sentinel) { + t.Fatal("an event holds the value") + } + if hits := grepState(sb.state, sentinel); len(hits) > 0 { + t.Fatalf("state files hold the value: %v", hits) + } + want := []string{"secret.requested", "secret.request.ended", "secret.saved", "secret.requested", "secret.request.ended", "secret.deleted"} + if got := sb.eventTypes(); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("events %v, want %v", got, want) + } +} + +func TestAProductionSecretNeedsItsNameTypedToDelete(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + t.Setenv("XDG_RUNTIME_DIR", "") + sb := newSecretBed(t, noKeyrings) + ctx := context.Background() + sr, err := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "ACME_PROD_KEY", Reason: "release", Environment: "production"}) + if err != nil { + t.Fatal(err) + } + // A production secret never expires, whatever the card sent. + call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": "prod-v", "expires": "1h"}, nil) + m, ok := sb.bx.secrets().Stores.Index().Get("box://ACME_PROD_KEY") + if !ok || m.Expires != nil || m.Environment != "production" { + t.Fatalf("meta %+v", m) + } + if status := call(t, sb.remote, "POST", "/v1/secrets/delete", "app", map[string]string{"ref": "box://ACME_PROD_KEY", "confirm": "acme_prod_key"}, nil); status != 400 { + t.Fatalf("delete without the name: %d", status) + } + if status := call(t, sb.remote, "POST", "/v1/secrets/delete", "app", map[string]string{"ref": "box://ACME_PROD_KEY", "confirm": "ACME_PROD_KEY"}, nil); status != 200 { + t.Fatalf("delete with the name: %d", status) + } +} + +func TestAnUnansweredSecretRequestTimesOutAfterAnHour(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + t.Setenv("XDG_RUNTIME_DIR", "") + sb := newSecretBed(t, noKeyrings) + ctx := context.Background() + sr, err := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "deploy"}) + if err != nil { + t.Fatal(err) + } + *sb.clock = sb.clock.Add(59 * time.Minute) + sb.bx.SweepSecrets(ctx) + if got, _ := sb.local.SecretRequest(ctx, sr.ID); got.Status != "pending" { + t.Fatalf("at 59 minutes: %s", got.Status) + } + *sb.clock = sb.clock.Add(2 * time.Minute) + sb.bx.SweepSecrets(ctx) + if got, _ := sb.local.SecretRequest(ctx, sr.ID); got.Status != "timed_out" { + t.Fatalf("at 61 minutes: %s", got.Status) + } + reps := sb.pendingReports("lead") + if len(reps) != 1 || reps[0].Status != "timed_out" || !strings.Contains(NotificationText(reps, 0), "Nobody answered") { + t.Fatalf("reports %+v", reps) + } + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": "late"}, nil); status != 409 { + t.Fatalf("a save after the timeout: %d", status) + } +} + +func TestTheSessionHearsBackInItsOwnTerminal(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + t.Setenv("XDG_RUNTIME_DIR", "") + sb := newSecretBed(t, noKeyrings) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go sb.bx.Reports.Run(ctx, sb.bx) + sr, err := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "deploy"}) + if err != nil { + t.Fatal(err) + } + call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": "typed-sentinel-55"}, nil) + deadline := time.Now().Add(15 * time.Second) + for { + screen, _ := sb.bx.Sessions.Screen(ctx, "lead", 200) + flat := strings.ReplaceAll(screen, "\n", "") + if strings.Contains(flat, "") { + if !strings.Contains(flat, `kind="secret"`) || !strings.Contains(flat, `ref="box://ACME_TOKEN"`) || strings.Contains(flat, "typed-sentinel-55") { + t.Fatalf("the notification:\n%s", screen) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("lead was never told:\n%s", screen) + } + time.Sleep(200 * time.Millisecond) + } +} + +// acmeVault is a third provider, made in this file alone: requests, +// resolution, listing and delete reach it with nothing else changed. +type acmeVault struct { + mu sync.Mutex + values map[string]string +} + +var theAcmeVault = &acmeVault{values: map[string]string{}} + +type acmeVaultStore struct{ where string } + +func (acmeVaultStore) Label() string { return "Acme Vault" } + +func (a acmeVaultStore) Save(ctx context.Context, s SecretSave) (string, error) { + theAcmeVault.mu.Lock() + defer theAcmeVault.mu.Unlock() + theAcmeVault.values[a.where+"/"+s.Name] = s.Value + return "acmevault://" + a.where + "/" + s.Name, nil +} + +func (a acmeVaultStore) Resolve(ctx context.Context, ref string) (string, error) { + theAcmeVault.mu.Lock() + defer theAcmeVault.mu.Unlock() + v, ok := theAcmeVault.values[strings.TrimPrefix(ref, "acmevault://")] + if !ok { + return "", errSecretNotFound + } + return v, nil +} + +func (a acmeVaultStore) List(ctx context.Context) ([]SecretMeta, error) { + theAcmeVault.mu.Lock() + defer theAcmeVault.mu.Unlock() + var out []SecretMeta + for k := range theAcmeVault.values { + team, name, _ := strings.Cut(k, "/") + if team == a.where { + out = append(out, SecretMeta{Name: name, Ref: "acmevault://" + k, Store: "acmevault", StoreLabel: "Acme Vault", Where: team}) + } + } + return out, nil +} + +func (a acmeVaultStore) Delete(ctx context.Context, ref string) error { + theAcmeVault.mu.Lock() + defer theAcmeVault.mu.Unlock() + delete(theAcmeVault.values, strings.TrimPrefix(ref, "acmevault://")) + return nil +} + +func registerAcmeVault(t *testing.T) { + registerSecretStore(&secretProvider{ + ID: "acmevault", Scheme: "acmevault", Name: "Acme Vault", + ValidateRef: func(ref string) error { + if !strings.HasPrefix(ref, "acmevault://") { + return errors.New("not an acme vault reference") + } + return nil + }, + WhereOf: func(ref string) string { team, _, _ := strings.Cut(strings.TrimPrefix(ref, "acmevault://"), "/"); return team }, + Open: func(env StoreEnv) SecretStore { return acmeVaultStore{where: firstNonEmptyStr(env.Where, "acme")} }, + }) + t.Cleanup(func() { + secretProvidersMu.Lock() + delete(secretProviders, "acmevault") + secretProvidersMu.Unlock() + }) +} + +func TestAThirdProviderPlugsInFromItsOwnFile(t *testing.T) { + registerAcmeVault(t) + sb := newSecretBed(t, noKeyrings) + ctx := context.Background() + if !IsSecretRef("acmevault://acme/ACME_TOKEN") { + t.Fatal("its references aren't references") + } + // The project's choice sends requests there. + if status := call(t, sb.remote, "PUT", "/v1/secrets/settings", "app", SecretSettings{Store: "local", Locations: map[string]SecretStoreChoice{"acme": {Store: "acmevault"}}}, nil); status != 200 { + t.Fatalf("settings: %d", status) + } + sr, err := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "deploy"}) + if err != nil || sr.Store != "acmevault" || sr.StoreLabel != "Acme Vault" { + t.Fatalf("request %+v %v", sr, err) + } + var saved map[string]string + call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": "vault-v"}, &saved) + if saved["ref"] != "acmevault://acme/ACME_TOKEN" { + t.Fatalf("saved %v", saved) + } + if v, err := sb.bx.secrets().Resolve(ctx, saved["ref"], nil, true); err != nil || v != "vault-v" { + t.Fatalf("resolve %q %v", v, err) + } + var list SavedSecrets + call(t, sb.remote, "GET", "/v1/secrets", "", nil, &list) + found := false + for _, m := range list.Secrets { + found = found || (m.Ref == saved["ref"] && m.Session == "lead") + } + hasStore := false + for _, s := range list.Stores { + hasStore = hasStore || s.ID == "acmevault" + } + if !found || !hasStore || list.Settings.Locations["acme"].Store != "acmevault" { + t.Fatalf("list %+v", list) + } + // The request's own --store wins over the project's. + local, err := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "OTHER_TOKEN", Reason: "x", Store: "local"}) + if err != nil || local.Store != "local" { + t.Fatalf("--store local: %+v %v", local, err) + } + if status := call(t, sb.remote, "POST", "/v1/secrets/delete", "app", map[string]string{"ref": saved["ref"]}, nil); status != 200 { + t.Fatalf("delete %d", status) + } + if _, err := sb.bx.secrets().Resolve(ctx, saved["ref"], nil, true); err == nil { + t.Fatal("still there") + } + // The project's override sits in the box's own config for it, never + // the repository's file. + cfg, _ := sb.bx.Locations.Config(ctx, "acme") + if cfg.Local.Secrets == nil || cfg.Local.Secrets.Store != "acmevault" || (cfg.Repo != nil && cfg.Repo.Secrets != nil) { + t.Fatalf("config %+v", cfg.Local.Secrets) + } +} diff --git a/internal/box/secrets.go b/internal/box/secrets.go index 68975d66f..134f03d61 100644 --- a/internal/box/secrets.go +++ b/internal/box/secrets.go @@ -28,22 +28,24 @@ import ( // // "DATABASE_PASSWORD": "op://dev/shop-db/password" 1Password, through the box's op CLI // "STRIPE_KEY": "env://STRIPE_TEST_KEY" a variable from berthd's own environment +// "ACME_TOKEN": "box://ACME_TOKEN" a secret saved on the box (secretstore.go) +// "ACME_KEY": "gcp://projects/acme/secrets/ACME_KEY" one saved in Secret Manager // // References are resolved on the box, when a worktree's environment is // built for a session, hook, flow or service. Resolved values live in memory -// for a few minutes and nowhere else: never on disk, in a log, an event, an -// API response or an error message. A reference that cannot be resolved -// leaves its variable unset, and the box says so once with a secret.failed -// event. +// for a few minutes and nowhere else: never in a log, an event, an API +// response or an error message, and on disk only in the store a person saved +// them to. A reference that cannot be resolved leaves its variable unset, and +// the box says so once with a secret.failed event. -// secretSchemes are the providers a reference can name. Another provider -// slots in as a scheme here and a case in lookup. +// secretSchemes are the providers a reference can name besides the saved +// secrets' stores, which register their own (secretstore.go). var secretSchemes = map[string]bool{"op": true, "env": true} // IsSecretRef says whether v names a secret rather than holding a value. func IsSecretRef(v string) bool { scheme, _, ok := strings.Cut(v, "://") - return ok && secretSchemes[scheme] + return ok && (secretSchemes[scheme] || secretProviderByScheme(scheme) != nil) } // opRef is 1Password's own syntax: op://vault/item/[section/]field, with an @@ -65,7 +67,10 @@ func ValidateSecretRef(ref string) error { } return nil } - return fmt.Errorf("%q is not a secret reference; use op://vault/item/field or env://NAME", ref) + if p := secretProviderByScheme(scheme); p != nil { + return p.ValidateRef(ref) + } + return fmt.Errorf("%q is not a secret reference; use op://vault/item/field, env://NAME or box://NAME", ref) } // validateEnvRefs checks the references among env's values. @@ -126,6 +131,9 @@ type Secrets struct { SessionFile string // Box names the box in errors; empty is the hostname. Box string + // Stores reads the secrets saved through secret cards (box://, gcp://); + // nil reads none. + Stores *SecretStores mu sync.Mutex cache map[string]*secretEntry @@ -155,6 +163,13 @@ func orDefault(d, def time.Duration) time.Duration { return def } +// Forget drops what was resolved for ref, so a replaced value is read anew. +func (s *Secrets) Forget(ref string) { + s.mu.Lock() + delete(s.cache, ref) + s.mu.Unlock() +} + // Resolve returns ref's value, from memory when it was resolved in the last // few minutes unless fresh is set. opEnv adds settings for the 1Password CLI // (OP_SERVICE_ACCOUNT_TOKEN, OP_ACCOUNT) to berthd's own environment. Errors @@ -252,6 +267,9 @@ func (s *Secrets) lookup(ctx context.Context, ref string, opEnv []string) (strin case "op": return s.opRead(ctx, ref, opEnv) } + if s.Stores != nil && secretProviderByScheme(scheme) != nil { + return s.Stores.Resolve(ctx, ref) + } return "", fmt.Errorf("no provider for %s:// references", scheme) } diff --git a/internal/box/secretstore.go b/internal/box/secretstore.go new file mode 100644 index 000000000..e9db585ca --- /dev/null +++ b/internal/box/secretstore.go @@ -0,0 +1,439 @@ +package box + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/cosscom/shipyard/internal/statefile" +) + +// Saved secrets: a value a person typed into a secret card is kept by a +// store, and an agent gets a reference to it (box://NAME, gcp://…), never +// the value. Each store is a provider behind one interface, registered by +// id and reference scheme, so another provider (Vercel, AWS) is one file: +// the card, the API, `berthd secret exec` and Settings only ever go +// through SecretStore. +// +// Besides what each store keeps, the box keeps an index of the secrets it +// saved (name, store, environment, who asked and why, when it expires; +// never a value), which reuse, Settings and the expiry sweep read. + +// SecretStore is one provider's store, opened for one place in it (a GCP +// project; "" for a provider with one place). +type SecretStore interface { + // Label says where a value goes, for the card and Settings: + // "Keychain", "Encrypted file", "GCP Secret Manager". + Label() string + // Save keeps a value and returns the reference that reads it. + Save(ctx context.Context, s SecretSave) (ref string, err error) + // Resolve reads a reference's value. + Resolve(ctx context.Context, ref string) (string, error) + // List is what the store holds that Shipyard saved, without values. + List(ctx context.Context) ([]SecretMeta, error) + // Delete removes a reference's secret. + Delete(ctx context.Context, ref string) error +} + +// SecretSave is one value to keep, and what it is for. +type SecretSave struct { + Name string + Value string + Environment string + Location string + // Grant is a service account to give read access to this one secret, + // where the store can (StoreCaps.Grant). + Grant string +} + +// StoreCaps is what a provider can do beyond keeping values. +type StoreCaps struct { + // Expiry: a saved secret can be given a lifetime, after which the box + // deletes it. + Expiry bool `json:"expiry"` + // Labels: the store marks what Shipyard saved, so it can list it. + Labels bool `json:"labels"` + // Grant: the store can give one service account access to one secret. + Grant bool `json:"grant"` +} + +// StoreEnv is what a provider opens its store with. +type StoreEnv struct { + // Dir is the box's state folder; Box names the box. + Dir string + Box string + // Where is the place in the provider: a GCP project. + Where string + // Index is the box's own record of what it saved. + Index *SecretIndex + // Bins overrides the programs a provider runs, by name ("security", + // "secret-tool", "gcloud"): tests' fakes. + Bins map[string]string +} + +func (e StoreEnv) bin(name string) (string, error) { + if p := e.Bins[name]; p != "" { + if _, err := os.Stat(p); err != nil { + return "", err + } + return p, nil + } + return exec.LookPath(name) +} + +// secretProvider is one registered provider. +type secretProvider struct { + // ID is what a request's --store names; Scheme the references it + // reads ("box" reads box://NAME). + ID, Scheme string + // Name is the provider, for the store list ("Local", "GCP Secret Manager"). + Name string + Caps StoreCaps + // NeedsWhere: the store must be told a place (a GCP project). + NeedsWhere bool + // ValidateRef says what is wrong with a reference to this provider. + ValidateRef func(ref string) error + // WhereOf is the place a reference names, so it resolves without the + // settings that saved it. + WhereOf func(ref string) string + // RefName is the secret's name in a reference. + RefName func(ref string) string + Open func(env StoreEnv) SecretStore +} + +var ( + secretProvidersMu sync.RWMutex + secretProviders = map[string]*secretProvider{} +) + +// registerSecretStore adds a provider; each provider's file registers +// itself in an init. +func registerSecretStore(p *secretProvider) { + secretProvidersMu.Lock() + defer secretProvidersMu.Unlock() + secretProviders[p.ID] = p +} + +func secretProviderByID(id string) *secretProvider { + secretProvidersMu.RLock() + defer secretProvidersMu.RUnlock() + return secretProviders[id] +} + +func secretProviderByScheme(scheme string) *secretProvider { + secretProvidersMu.RLock() + defer secretProvidersMu.RUnlock() + for _, p := range secretProviders { + if p.Scheme == scheme { + return p + } + } + return nil +} + +func secretProviderOf(ref string) *secretProvider { + scheme, _, ok := strings.Cut(ref, "://") + if !ok { + return nil + } + return secretProviderByScheme(scheme) +} + +// secretProviderIDs are the registered providers, local first. +func secretProviderIDs() []string { + secretProvidersMu.RLock() + defer secretProvidersMu.RUnlock() + ids := make([]string, 0, len(secretProviders)) + for id := range secretProviders { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { + if (ids[i] == "local") != (ids[j] == "local") { + return ids[i] == "local" + } + return ids[i] < ids[j] + }) + return ids +} + +// secretName is what a saved secret may be called: an environment +// variable's name. +var secretName = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`) + +// SecretMeta is one saved secret as Settings lists it: never a value. +type SecretMeta struct { + Name string `json:"name"` + Ref string `json:"ref"` + Store string `json:"store"` + // StoreLabel is where the value is ("Keychain", "GCP Secret Manager"); + // Where the place in the store (a GCP project). + StoreLabel string `json:"store_label"` + Where string `json:"where,omitempty"` + Backend string `json:"backend,omitempty"` + Environment string `json:"environment,omitempty"` + Created time.Time `json:"created"` + Expires *time.Time `json:"expires,omitempty"` + Session string `json:"session,omitempty"` + Reason string `json:"reason,omitempty"` + Location string `json:"location,omitempty"` +} + +func (m SecretMeta) expired(now time.Time) bool { + return m.Expires != nil && !now.Before(*m.Expires) +} + +// SecretIndex is the box's record of the secrets it saved, in one private +// file. It never holds a value. +type SecretIndex struct { + Path string + mu sync.Mutex +} + +func (x *SecretIndex) read() []SecretMeta { + var all []SecretMeta + if b, err := os.ReadFile(x.Path); err == nil { + json.Unmarshal(b, &all) + } + return all +} + +// All is every secret the box saved. +func (x *SecretIndex) All() []SecretMeta { + x.mu.Lock() + defer x.mu.Unlock() + return x.read() +} + +// Get is the secret a reference names. +func (x *SecretIndex) Get(ref string) (SecretMeta, bool) { + for _, m := range x.All() { + if m.Ref == ref { + return m, true + } + } + return SecretMeta{}, false +} + +// Put records a saved secret, replacing what was recorded for its +// reference. +func (x *SecretIndex) Put(m SecretMeta) error { + return x.update(func(all []SecretMeta) []SecretMeta { + out := all[:0] + for _, o := range all { + if o.Ref != m.Ref { + out = append(out, o) + } + } + return append(out, m) + }) +} + +// Remove forgets a reference. +func (x *SecretIndex) Remove(ref string) error { + return x.update(func(all []SecretMeta) []SecretMeta { + out := all[:0] + for _, o := range all { + if o.Ref != ref { + out = append(out, o) + } + } + return out + }) +} + +func (x *SecretIndex) update(f func([]SecretMeta) []SecretMeta) error { + x.mu.Lock() + defer x.mu.Unlock() + unlock, err := statefile.Lock(x.Path) + if err != nil { + return err + } + defer unlock() + all := f(x.read()) + if all == nil { + all = []SecretMeta{} + } + b, err := json.MarshalIndent(all, "", " ") + if err != nil { + return err + } + return statefile.Write(x.Path, b) +} + +// SecretStores opens the box's stores: the registered providers, with the +// box's settings for where each keeps things. +type SecretStores struct { + // Dir is the box's state folder; the index and the local store live in + // it. + Dir string + Box string + // Bins overrides providers' programs (tests). + Bins map[string]string + // Now is the clock; nil is time.Now. + Now func() time.Time + + once sync.Once + index *SecretIndex +} + +func (s *SecretStores) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +// Index is the box's record of what it saved. +func (s *SecretStores) Index() *SecretIndex { + s.once.Do(func() { s.index = &SecretIndex{Path: filepath.Join(s.Dir, "secrets", "index.json")} }) + return s.index +} + +func (s *SecretStores) env(where string) StoreEnv { + return StoreEnv{Dir: s.Dir, Box: s.Box, Where: where, Index: s.Index(), Bins: s.Bins} +} + +// Open is provider id's store at where. +func (s *SecretStores) Open(id, where string) (SecretStore, error) { + p := secretProviderByID(id) + if p == nil { + return nil, fmt.Errorf("there is no secret store %q; this box has %s", id, strings.Join(secretProviderIDs(), ", ")) + } + if p.NeedsWhere && where == "" { + return nil, fmt.Errorf("the %s store needs a project: set it in Settings → Secrets", p.Name) + } + return p.Open(s.env(where)), nil +} + +// ForRef is the store that reads ref. +func (s *SecretStores) ForRef(ref string) (SecretStore, error) { + p := secretProviderOf(ref) + if p == nil { + return nil, fmt.Errorf("%q is not a saved secret's reference", ref) + } + if err := p.ValidateRef(ref); err != nil { + return nil, err + } + where := "" + if p.WhereOf != nil { + where = p.WhereOf(ref) + } + return p.Open(s.env(where)), nil +} + +// errSecretExpired is a saved secret read after its time. +var errSecretExpired = errors.New("expired") + +// Resolve reads a saved secret's reference; one past its expiry reads as +// gone even before the sweep deletes it. +func (s *SecretStores) Resolve(ctx context.Context, ref string) (string, error) { + st, err := s.ForRef(ref) + if err != nil { + return "", err + } + if m, ok := s.Index().Get(ref); ok && m.expired(s.now()) { + return "", fmt.Errorf("%s %w on %s", m.Name, errSecretExpired, m.Expires.Local().Format("2 Jan 15:04")) + } + return st.Resolve(ctx, ref) +} + +// Sweep deletes the saved secrets whose time is up, from their stores and +// the index. It returns the names it deleted. +func (s *SecretStores) Sweep(ctx context.Context) []string { + now := s.now() + var gone []string + for _, m := range s.Index().All() { + if !m.expired(now) { + continue + } + st, err := s.ForRef(m.Ref) + if err == nil { + err = st.Delete(ctx, m.Ref) + } + if err != nil && !errors.Is(err, errSecretNotFound) { + continue + } + s.Index().Remove(m.Ref) + gone = append(gone, m.Name) + } + return gone +} + +// errSecretNotFound is a store with no such secret. +var errSecretNotFound = errors.New("no such secret") + +// SecretSettings is where this box saves secrets: a default store, and +// projects of its own. The per-project choices live in each location's +// box-local config (RepoConfig.Secrets), never in the repository's file. +type SecretSettings struct { + Store string `json:"store"` + GCPProject string `json:"gcp_project,omitempty"` + // Locations are the projects' own choices, by location. + Locations map[string]SecretStoreChoice `json:"locations,omitempty"` +} + +// SecretStoreChoice is a project's choice of store, over the box's. +type SecretStoreChoice struct { + Store string `json:"store,omitempty"` + GCPProject string `json:"gcp_project,omitempty"` +} + +func (c SecretStoreChoice) validate() error { + if c.Store != "" && secretProviderByID(c.Store) == nil { + return fmt.Errorf("there is no secret store %q", c.Store) + } + if c.GCPProject != "" && !gcpProjectID.MatchString(c.GCPProject) { + return fmt.Errorf("%q is not a GCP project ID", c.GCPProject) + } + return nil +} + +// whereFor is the place a store id is opened at, from a choice. +func (c SecretStoreChoice) whereFor(id string) string { + if id == "gcp" { + return c.GCPProject + } + return "" +} + +// secretSettingsFile keeps the box's own choice. +func (s *SecretStores) settingsPath() string { return filepath.Join(s.Dir, "secrets", "settings.json") } + +// BoxSettings is the box's default store; local unless set. +func (s *SecretStores) BoxSettings() SecretStoreChoice { + var c SecretStoreChoice + if b, err := os.ReadFile(s.settingsPath()); err == nil { + json.Unmarshal(b, &c) + } + if c.Store == "" || secretProviderByID(c.Store) == nil { + c.Store = "local" + } + return c +} + +// SetBoxSettings keeps the box's default store. +func (s *SecretStores) SetBoxSettings(c SecretStoreChoice) error { + if err := c.validate(); err != nil { + return err + } + b, _ := json.Marshal(c) + return statefile.Write(s.settingsPath(), b) +} + +// Choose is the store a request saves to: the request's own --store, then +// the project's choice, then the box's default. A GCP project comes the +// same way: the project's, then the box's. +func Choose(request string, project, boxDefault SecretStoreChoice) (id, where string) { + id = firstNonEmptyStr(request, project.Store, boxDefault.Store, "local") + where = firstNonEmptyStr(project.whereFor(id), boxDefault.whereFor(id)) + return id, where +} diff --git a/internal/box/secretstore_gcp.go b/internal/box/secretstore_gcp.go new file mode 100644 index 000000000..63ce80612 --- /dev/null +++ b/internal/box/secretstore_gcp.go @@ -0,0 +1,232 @@ +package box + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +// The GCP store keeps values in Google Cloud Secret Manager, in a project +// the box's settings name, with the credentials the box's gcloud already +// has. berthd never changes a project's IAM: a missing permission fails on +// the card, naming it. It only ever writes to secrets it labelled itself +// (shipyard=true): one by that name without the label is refused, not +// given a new version. +// +// secretmanager.secrets.create, .get, .list save and list +// secretmanager.versions.add, .access save and read +// secretmanager.secrets.delete delete +// secretmanager.secrets.setIamPolicy --grant only + +func init() { + registerSecretStore(&secretProvider{ + ID: "gcp", Scheme: "gcp", Name: "GCP Secret Manager", + Caps: StoreCaps{Expiry: true, Labels: true, Grant: true}, + NeedsWhere: true, + ValidateRef: validateGCPRef, + WhereOf: func(ref string) string { + if m := gcpRef.FindStringSubmatch(ref); m != nil { + return m[1] + } + return "" + }, + RefName: func(ref string) string { + if m := gcpRef.FindStringSubmatch(ref); m != nil { + return m[2] + } + return "" + }, + Open: func(env StoreEnv) SecretStore { return &gcpStore{env: env} }, + }) +} + +var ( + gcpProjectID = regexp.MustCompile(`^[a-z][a-z0-9-]{4,28}[a-z0-9]$`) + gcpRef = regexp.MustCompile(`^gcp://projects/([a-z][a-z0-9-]{4,28}[a-z0-9])/secrets/([A-Za-z0-9_-]{1,255})(?:/versions/(latest|[0-9]+))?$`) + gcpDenied = regexp.MustCompile(`Permission '([a-zA-Z.]+)' denied`) + gcpLabelBad = regexp.MustCompile(`[^a-z0-9_-]+`) + saEmail = regexp.MustCompile(`^[a-z0-9-]{1,63}@[a-z0-9-]+\.iam\.gserviceaccount\.com$`) +) + +func validateGCPRef(ref string) error { + if !gcpRef.MatchString(ref) { + return fmt.Errorf("%q is not a Secret Manager reference; they look like gcp://projects/PROJECT/secrets/NAME", ref) + } + return nil +} + +// ValidateServiceAccount says whether s is a service account's email. +func ValidateServiceAccount(s string) error { + if !saEmail.MatchString(s) { + return fmt.Errorf("%q is not a service account; they look like name@project.iam.gserviceaccount.com", s) + } + return nil +} + +type gcpStore struct { + env StoreEnv +} + +func (g *gcpStore) Label() string { return "GCP Secret Manager" } + +func (g *gcpStore) ref(id string) string { + return "gcp://projects/" + g.env.Where + "/secrets/" + id +} + +// gcloud runs one gcloud command against the store's project, never +// asking, with stdin for a value. denied is the permission that command +// needs, named when gcloud's own message doesn't. +func (g *gcpStore) gcloud(ctx context.Context, denied, stdin string, args ...string) (string, error) { + bin, err := g.env.bin("gcloud") + if err != nil { + return "", errors.New("gcloud is not installed on this box: Secret Manager is read and written with it") + } + args = append(args, "--project="+g.env.Where, "--quiet") + out, stderr, err := runQuiet(ctx, bin, stdin, args...) + if err == nil { + return out, nil + } + return "", gcloudError(stderr, err, denied, g.env.Where) +} + +// gcloudError says what went wrong, naming a missing permission. +func gcloudError(stderr string, err error, denied, project string) error { + switch { + case strings.Contains(stderr, "NOT_FOUND"): + return errSecretNotFound + case gcpDenied.MatchString(stderr): + return fmt.Errorf("missing permission %s on project %s", gcpDenied.FindStringSubmatch(stderr)[1], project) + case strings.Contains(stderr, "PERMISSION_DENIED") && denied != "": + return fmt.Errorf("missing permission %s on project %s", denied, project) + case strings.Contains(stderr, "gcloud auth login") || strings.Contains(stderr, "credentials"): + return errors.New("gcloud isn't signed in on this box: run gcloud auth login there") + } + lines := strings.Split(strings.TrimSpace(stderr), "\n") + msg := strings.TrimPrefix(strings.TrimSpace(lines[len(lines)-1]), "ERROR: ") + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("gcloud: %s", msg) +} + +type gcpSecret struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + CreateTime time.Time `json:"createTime"` +} + +func (s gcpSecret) ours() bool { return s.Labels["shipyard"] == "true" } + +func (g *gcpStore) describe(ctx context.Context, id string) (gcpSecret, error) { + out, err := g.gcloud(ctx, "secretmanager.secrets.get", "", "secrets", "describe", id, "--format=json") + if err != nil { + return gcpSecret{}, err + } + var s gcpSecret + if err := json.Unmarshal([]byte(out), &s); err != nil { + return gcpSecret{}, fmt.Errorf("gcloud described %s in a way berthd can't read", id) + } + return s, nil +} + +// gcpLabel is a value as a label holds it: lowercase letters, digits, _ +// and -, at most 63. +func gcpLabel(v string) string { + v = strings.Trim(gcpLabelBad.ReplaceAllString(strings.ToLower(v), "-"), "-") + if len(v) > 63 { + v = v[:63] + } + return v +} + +func (g *gcpStore) Save(ctx context.Context, s SecretSave) (string, error) { + if !secretName.MatchString(s.Name) { + return "", fmt.Errorf("%q is not a secret name", s.Name) + } + if s.Grant != "" { + if err := ValidateServiceAccount(s.Grant); err != nil { + return "", err + } + } + existing, err := g.describe(ctx, s.Name) + switch { + case errors.Is(err, errSecretNotFound): + env := firstNonEmptyStr(s.Environment, "staging") + labels := []string{"shipyard=true", "shipyard-environment=" + gcpLabel(env)} + if l := gcpLabel(s.Location); l != "" { + labels = append(labels, "shipyard-location="+l) + } + if l := gcpLabel(g.env.Box); l != "" { + labels = append(labels, "shipyard-box="+l) + } + if _, err := g.gcloud(ctx, "secretmanager.secrets.create", "", "secrets", "create", s.Name, "--replication-policy=automatic", "--labels="+strings.Join(labels, ",")); err != nil { + return "", err + } + case err != nil: + return "", err + case !existing.ours(): + return "", fmt.Errorf("project %s already has a secret called %s that Shipyard didn't make (it has no shipyard label), so it was left alone: pick another name, or label it shipyard=true to let Shipyard add versions", g.env.Where, s.Name) + } + if _, err := g.gcloud(ctx, "secretmanager.versions.add", s.Value, "secrets", "versions", "add", s.Name, "--data-file=-"); err != nil { + return "", err + } + if s.Grant != "" { + if _, err := g.gcloud(ctx, "secretmanager.secrets.setIamPolicy", "", "secrets", "add-iam-policy-binding", s.Name, "--member=serviceAccount:"+s.Grant, "--role=roles/secretmanager.secretAccessor"); err != nil { + return "", fmt.Errorf("saved %s, but could not grant %s: %w", s.Name, s.Grant, err) + } + } + return g.ref(s.Name), nil +} + +func (g *gcpStore) Resolve(ctx context.Context, ref string) (string, error) { + m := gcpRef.FindStringSubmatch(ref) + if m == nil { + return "", validateGCPRef(ref) + } + version := firstNonEmptyStr(m[3], "latest") + out, err := g.gcloud(ctx, "secretmanager.versions.access", "", "secrets", "versions", "access", version, "--secret="+m[2]) + if errors.Is(err, errSecretNotFound) { + return "", fmt.Errorf("%s has no such version in project %s", m[2], m[1]) + } + return out, err +} + +func (g *gcpStore) List(ctx context.Context) ([]SecretMeta, error) { + out, err := g.gcloud(ctx, "secretmanager.secrets.list", "", "secrets", "list", "--filter=labels.shipyard=true", "--format=json") + if err != nil { + return nil, err + } + var all []gcpSecret + if err := json.Unmarshal([]byte(out), &all); err != nil { + return nil, errors.New("gcloud listed secrets in a way berthd can't read") + } + var metas []SecretMeta + for _, s := range all { + if !s.ours() { + continue + } + id := s.Name[strings.LastIndex(s.Name, "/")+1:] + metas = append(metas, SecretMeta{Name: id, Ref: g.ref(id), Store: "gcp", StoreLabel: g.Label(), Where: g.env.Where, Environment: s.Labels["shipyard-environment"], Location: s.Labels["shipyard-location"], Created: s.CreateTime}) + } + return metas, nil +} + +func (g *gcpStore) Delete(ctx context.Context, ref string) error { + m := gcpRef.FindStringSubmatch(ref) + if m == nil { + return validateGCPRef(ref) + } + s, err := g.describe(ctx, m[2]) + if err != nil { + return err + } + if !s.ours() { + return fmt.Errorf("%s in project %s isn't one Shipyard made (no shipyard label): delete it in the console", m[2], m[1]) + } + _, err = g.gcloud(ctx, "secretmanager.secrets.delete", "", "secrets", "delete", m[2]) + return err +} diff --git a/internal/box/secretstore_local.go b/internal/box/secretstore_local.go new file mode 100644 index 000000000..18ac284b9 --- /dev/null +++ b/internal/box/secretstore_local.go @@ -0,0 +1,479 @@ +package box + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "golang.org/x/crypto/chacha20poly1305" + + "github.com/cosscom/shipyard/internal/statefile" +) + +// The local store keeps values on the box itself, in one namespace for the +// whole box: any agent on it can use any secret saved here. Where depends +// on the box: +// +// - macOS: the login Keychain, written through `security -i` with the +// value on its stdin, never in its arguments. +// - Linux with a Secret Service on the session bus (GNOME Keyring, +// KeePassXC): libsecret's secret-tool, the value on its stdin. +// - Otherwise an encrypted file in the box's state folder: +// XChaCha20-Poly1305 values, the key in a file of its own. That guards +// against a leak, a backup or a grep of the folder, not against another +// program running as the same user, which can read the key too. +// +// Which backend kept each secret is recorded beside it, so a box that gains +// a Secret Service later still reads what it saved before. + +func init() { + registerSecretStore(&secretProvider{ + ID: "local", Scheme: "box", Name: "Local", + Caps: StoreCaps{Expiry: true}, + ValidateRef: validateBoxRef, + RefName: func(ref string) string { return strings.TrimPrefix(ref, "box://") }, + Open: func(env StoreEnv) SecretStore { return &localStore{env: env} }, + }) +} + +func validateBoxRef(ref string) error { + name, ok := strings.CutPrefix(ref, "box://") + if !ok || !secretName.MatchString(name) { + return fmt.Errorf("%q is not a saved secret's reference; they look like box://NAME", ref) + } + return nil +} + +// The local backends. +const ( + backendKeychain = "keychain" + backendSecretService = "secret-service" + backendFile = "file" +) + +var backendLabels = map[string]string{backendKeychain: "Keychain", backendSecretService: "Secret Service", backendFile: "Encrypted file"} + +// localService is the Keychain item's service and secret-tool's attribute +// for the values the box keeps. +const localService = "dev.berth.secrets" + +type localStore struct { + env StoreEnv +} + +func (l *localStore) dir() string { return filepath.Join(l.env.Dir, "secrets") } + +// recordPath says which backend keeps each name. +func (l *localStore) recordPath() string { return filepath.Join(l.dir(), "local.json") } + +type localRecord struct { + Backend string `json:"backend"` + Created time.Time `json:"created"` +} + +var localMu sync.Mutex + +func (l *localStore) records() map[string]localRecord { + m := map[string]localRecord{} + if b, err := os.ReadFile(l.recordPath()); err == nil { + json.Unmarshal(b, &m) + } + return m +} + +func (l *localStore) updateRecords(f func(map[string]localRecord)) error { + localMu.Lock() + defer localMu.Unlock() + if err := os.MkdirAll(l.dir(), 0o700); err != nil { + return err + } + unlock, err := statefile.Lock(l.recordPath()) + if err != nil { + return err + } + defer unlock() + m := l.records() + f(m) + b, _ := json.MarshalIndent(m, "", " ") + return statefile.Write(l.recordPath(), b) +} + +// backend is where a new value goes on this box now. +func (l *localStore) backend(ctx context.Context) localBackend { + if runtime.GOOS == "darwin" || l.env.Bins["security"] != "" { + if bin, err := l.env.bin("security"); err == nil { + return &keychainBackend{bin: bin} + } + } + if bin, err := l.env.bin("secret-tool"); err == nil && secretServiceAnswers(ctx, bin) { + return &secretServiceBackend{bin: bin} + } + return &fileBackend{dir: l.dir(), keyPath: filepath.Join(l.env.Dir, "secret.key")} +} + +// backendFor is the backend that keeps name, as recorded. +func (l *localStore) backendFor(kind string) (localBackend, error) { + switch kind { + case backendKeychain: + bin, err := l.env.bin("security") + if err != nil { + return nil, errors.New("the Keychain's security command is missing") + } + return &keychainBackend{bin: bin}, nil + case backendSecretService: + bin, err := l.env.bin("secret-tool") + if err != nil { + return nil, errors.New("secret-tool (libsecret) is missing") + } + return &secretServiceBackend{bin: bin}, nil + case backendFile: + return &fileBackend{dir: l.dir(), keyPath: filepath.Join(l.env.Dir, "secret.key")}, nil + } + return nil, fmt.Errorf("unknown local backend %q", kind) +} + +func (l *localStore) Label() string { + return backendLabels[l.backend(context.Background()).kind()] +} + +// Detail is the label of the backend that keeps ref. +func (l *localStore) Detail(ref string) (backend, label string) { + r, ok := l.records()[strings.TrimPrefix(ref, "box://")] + if !ok { + return "", "" + } + return r.Backend, backendLabels[r.Backend] +} + +func (l *localStore) Save(ctx context.Context, s SecretSave) (string, error) { + if !secretName.MatchString(s.Name) { + return "", fmt.Errorf("%q is not a secret name", s.Name) + } + be := l.backend(ctx) + // A value that moves backend leaves nothing behind in the old one. + old, had := l.records()[s.Name] + if err := be.put(ctx, s.Name, s.Value); err != nil { + return "", err + } + if had && old.Backend != be.kind() { + if ob, err := l.backendFor(old.Backend); err == nil { + ob.del(ctx, s.Name) + } + } + err := l.updateRecords(func(m map[string]localRecord) { + m[s.Name] = localRecord{Backend: be.kind(), Created: time.Now().UTC()} + }) + if err != nil { + return "", err + } + return "box://" + s.Name, nil +} + +func (l *localStore) Resolve(ctx context.Context, ref string) (string, error) { + if err := validateBoxRef(ref); err != nil { + return "", err + } + name := strings.TrimPrefix(ref, "box://") + r, ok := l.records()[name] + if !ok { + return "", fmt.Errorf("%s is not saved on this box", name) + } + be, err := l.backendFor(r.Backend) + if err != nil { + return "", err + } + return be.get(ctx, name) +} + +func (l *localStore) List(ctx context.Context) ([]SecretMeta, error) { + var out []SecretMeta + for name, r := range l.records() { + out = append(out, SecretMeta{Name: name, Ref: "box://" + name, Store: "local", StoreLabel: backendLabels[r.Backend], Backend: r.Backend, Created: r.Created}) + } + return out, nil +} + +func (l *localStore) Delete(ctx context.Context, ref string) error { + if err := validateBoxRef(ref); err != nil { + return err + } + name := strings.TrimPrefix(ref, "box://") + r, ok := l.records()[name] + if !ok { + return fmt.Errorf("%s: %w", name, errSecretNotFound) + } + be, err := l.backendFor(r.Backend) + if err != nil { + return err + } + if err := be.del(ctx, name); err != nil && !errors.Is(err, errSecretNotFound) { + return err + } + return l.updateRecords(func(m map[string]localRecord) { delete(m, name) }) +} + +// localBackend keeps values for the local store. +type localBackend interface { + kind() string + put(ctx context.Context, name, value string) error + get(ctx context.Context, name string) (string, error) + del(ctx context.Context, name string) error +} + +const localTimeout = 15 * time.Second + +// runQuiet runs a backend's program with input on stdin and no terminal, +// and gives back what it printed. Errors carry the program's message, +// which never holds a value: values only go in on stdin. +func runQuiet(ctx context.Context, bin string, stdin string, args ...string) (string, string, error) { + ctx, cancel := context.WithTimeout(ctx, localTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + cmd.WaitDelay = time.Second + err := cmd.Run() + if ctx.Err() != nil { + err = fmt.Errorf("%s timed out", filepath.Base(bin)) + } + return stdout.String(), strings.TrimSpace(stderr.String()), err +} + +// keychainBackend is the login Keychain, through macOS's security command. +type keychainBackend struct{ bin string } + +func (k *keychainBackend) kind() string { return backendKeychain } + +func (k *keychainBackend) put(ctx context.Context, name, value string) error { + // security's interactive mode reads its commands from stdin, so the + // value never shows in a process list; -X takes it as hex, so no + // quoting can break it. + line := fmt.Sprintf("add-generic-password -U -a %s -s %s -l Shipyard:%s -X %s\n", name, localService, name, hex.EncodeToString([]byte(value))) + _, stderr, err := runQuiet(ctx, k.bin, line, "-i") + if err != nil { + return fmt.Errorf("the Keychain did not save %s: %s", name, firstNonEmptyStr(stderr, err.Error())) + } + // Interactive mode exits 0 whatever its commands did. + if _, stderr, err := runQuiet(ctx, k.bin, "", "find-generic-password", "-a", name, "-s", localService); err != nil { + return fmt.Errorf("the Keychain did not save %s: %s", name, firstNonEmptyStr(stderr, "it is locked or refused")) + } + return nil +} + +func (k *keychainBackend) get(ctx context.Context, name string) (string, error) { + out, stderr, err := runQuiet(ctx, k.bin, "", "find-generic-password", "-a", name, "-s", localService, "-w") + if err != nil { + if strings.Contains(stderr, "could not be found") { + return "", fmt.Errorf("%s is not in the Keychain", name) + } + return "", fmt.Errorf("the Keychain did not give %s: %s", name, firstNonEmptyStr(stderr, err.Error())) + } + return strings.TrimSuffix(out, "\n"), nil +} + +func (k *keychainBackend) del(ctx context.Context, name string) error { + _, stderr, err := runQuiet(ctx, k.bin, "", "delete-generic-password", "-a", name, "-s", localService) + if err != nil { + if strings.Contains(stderr, "could not be found") { + return errSecretNotFound + } + return fmt.Errorf("the Keychain did not delete %s: %s", name, firstNonEmptyStr(stderr, err.Error())) + } + return nil +} + +// secretServiceBackend is the session bus's Secret Service, through +// libsecret's secret-tool. +type secretServiceBackend struct{ bin string } + +func (s *secretServiceBackend) kind() string { return backendSecretService } + +func (s *secretServiceBackend) put(ctx context.Context, name, value string) error { + _, stderr, err := runQuiet(ctx, s.bin, value, "store", "--label=Shipyard: "+name, "service", localService, "name", name) + if err != nil { + return fmt.Errorf("the Secret Service did not save %s: %s", name, firstNonEmptyStr(stderr, err.Error())) + } + return nil +} + +func (s *secretServiceBackend) get(ctx context.Context, name string) (string, error) { + out, stderr, err := runQuiet(ctx, s.bin, "", "lookup", "service", localService, "name", name) + if err != nil { + if stderr == "" { + return "", fmt.Errorf("%s is not in the Secret Service", name) + } + return "", fmt.Errorf("the Secret Service did not give %s: %s", name, stderr) + } + return out, nil +} + +func (s *secretServiceBackend) del(ctx context.Context, name string) error { + _, stderr, err := runQuiet(ctx, s.bin, "", "clear", "service", localService, "name", name) + if err != nil && stderr != "" { + return fmt.Errorf("the Secret Service did not delete %s: %s", name, stderr) + } + return nil +} + +// secretServiceAnswers says whether a Secret Service answers on the +// session bus: a lookup that finds nothing exits 1 saying nothing, while +// one with no bus or no service says why. A locked keyring that would ask +// for a password, with no one to type it, times out the same as none. +func secretServiceAnswers(ctx context.Context, bin string) bool { + if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" { + if rt := os.Getenv("XDG_RUNTIME_DIR"); rt == "" { + return false + } else if _, err := os.Stat(filepath.Join(rt, "bus")); err != nil { + return false + } + } + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "lookup", "service", localService, "probe", "berth") + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.WaitDelay = time.Second + err := cmd.Run() + if ctx.Err() != nil { + return false + } + var ee *exec.ExitError + return err == nil || (errors.As(err, &ee) && ee.ExitCode() == 1 && strings.TrimSpace(stderr.String()) == "") +} + +// fileBackend keeps values encrypted in one file, with the key in another. +type fileBackend struct { + dir string + keyPath string +} + +func (f *fileBackend) kind() string { return backendFile } + +func (f *fileBackend) valuesPath() string { return filepath.Join(f.dir, "values.json") } + +var fileMu sync.Mutex + +// key reads the key, making it the first time. +func (f *fileBackend) key(create bool) ([]byte, error) { + b, err := os.ReadFile(f.keyPath) + if err == nil { + if len(b) != chacha20poly1305.KeySize { + return nil, fmt.Errorf("%s is not a key", f.keyPath) + } + return b, nil + } + if !os.IsNotExist(err) || !create { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(f.keyPath), 0o700); err != nil { + return nil, err + } + k := make([]byte, chacha20poly1305.KeySize) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return nil, err + } + out, err := os.OpenFile(f.keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if os.IsExist(err) { + return f.key(false) + } + if err != nil { + return nil, err + } + if _, err := out.Write(k); err != nil { + out.Close() + os.Remove(f.keyPath) + return nil, err + } + return k, out.Close() +} + +func (f *fileBackend) values() map[string]string { + m := map[string]string{} + if b, err := os.ReadFile(f.valuesPath()); err == nil { + json.Unmarshal(b, &m) + } + return m +} + +func (f *fileBackend) write(fn func(map[string]string)) error { + fileMu.Lock() + defer fileMu.Unlock() + if err := os.MkdirAll(f.dir, 0o700); err != nil { + return err + } + os.Chmod(f.dir, 0o700) + unlock, err := statefile.Lock(f.valuesPath()) + if err != nil { + return err + } + defer unlock() + m := f.values() + fn(m) + b, _ := json.MarshalIndent(m, "", " ") + return statefile.Write(f.valuesPath(), b) +} + +func (f *fileBackend) put(ctx context.Context, name, value string) error { + k, err := f.key(true) + if err != nil { + return fmt.Errorf("the box's secret key: %w", err) + } + aead, err := chacha20poly1305.NewX(k) + if err != nil { + return err + } + nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+len(value)+aead.Overhead()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return err + } + // The name is bound in, so a value can't be passed off as another's. + sealed := aead.Seal(nonce, nonce, []byte(value), []byte(name)) + return f.write(func(m map[string]string) { m[name] = base64.StdEncoding.EncodeToString(sealed) }) +} + +func (f *fileBackend) get(ctx context.Context, name string) (string, error) { + enc, ok := f.values()[name] + if !ok { + return "", fmt.Errorf("%s is not in the box's encrypted file", name) + } + k, err := f.key(false) + if err != nil { + return "", fmt.Errorf("the box's secret key: %w", err) + } + aead, err := chacha20poly1305.NewX(k) + if err != nil { + return "", err + } + sealed, err := base64.StdEncoding.DecodeString(enc) + if err != nil || len(sealed) < aead.NonceSize() { + return "", fmt.Errorf("%s is damaged in the box's encrypted file", name) + } + v, err := aead.Open(nil, sealed[:aead.NonceSize()], sealed[aead.NonceSize():], []byte(name)) + if err != nil { + return "", fmt.Errorf("%s does not decrypt with the box's key", name) + } + return string(v), nil +} + +func (f *fileBackend) del(ctx context.Context, name string) error { + if _, ok := f.values()[name]; !ok { + return errSecretNotFound + } + return f.write(func(m map[string]string) { delete(m, name) }) +} diff --git a/internal/box/secretstore_test.go b/internal/box/secretstore_test.go new file mode 100644 index 000000000..2cd3bca98 --- /dev/null +++ b/internal/box/secretstore_test.go @@ -0,0 +1,574 @@ +package box + +import ( + "bufio" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The stores' programs are faked by this test binary itself: TestMain runs +// fakeProgram when BERTH_TEST_FAKE names one, keeping its state in +// BERTH_TEST_FAKE_DIR and logging every argv it was given, so a test can +// check no value ever reached one. + +// fakeBins points a store's programs at this binary, as fake, with dir for +// their state. +func fakeBins(t *testing.T, fake string) (map[string]string, string) { + t.Helper() + dir := t.TempDir() + t.Setenv("BERTH_TEST_FAKE", fake) + t.Setenv("BERTH_TEST_FAKE_DIR", dir) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return map[string]string{fake: exe}, dir +} + +func fakeProgram() int { + dir := os.Getenv("BERTH_TEST_FAKE_DIR") + args := os.Args[1:] + if f, err := os.OpenFile(filepath.Join(dir, "argv.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil { + fmt.Fprintln(f, strings.Join(args, " ")) + f.Close() + } + switch os.Getenv("BERTH_TEST_FAKE") { + case "security": + return fakeSecurity(dir, args) + case "secret-tool": + return fakeSecretTool(dir, args) + case "gcloud": + return fakeGcloud(dir, args) + } + return 2 +} + +func flagValue(args []string, name string) string { + for i, a := range args { + if a == name && i+1 < len(args) { + return args[i+1] + } + if v, ok := strings.CutPrefix(a, name+"="); ok { + return v + } + } + return "" +} + +func fakeSecurity(dir string, args []string) int { + item := func(name string) string { return filepath.Join(dir, "kc-"+name) } + switch args[0] { + case "-i": + sc := bufio.NewScanner(os.Stdin) + for sc.Scan() { + f := strings.Fields(sc.Text()) + if len(f) == 0 || f[0] != "add-generic-password" { + continue + } + v, err := hex.DecodeString(flagValue(f, "-X")) + if err != nil { + fmt.Fprintln(os.Stderr, "bad hex") + continue + } + os.WriteFile(item(flagValue(f, "-a")), v, 0o600) + } + return 0 + case "find-generic-password": + v, err := os.ReadFile(item(flagValue(args, "-a"))) + if err != nil { + fmt.Fprintln(os.Stderr, "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.") + return 44 + } + for _, a := range args { + if a == "-w" { + fmt.Println(string(v)) + } + } + return 0 + case "delete-generic-password": + if err := os.Remove(item(flagValue(args, "-a"))); err != nil { + fmt.Fprintln(os.Stderr, "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.") + return 44 + } + return 0 + } + return 2 +} + +func fakeSecretTool(dir string, args []string) int { + if os.Getenv("BERTH_TEST_FAKE_MODE") == "nobus" { + fmt.Fprintln(os.Stderr, "secret-tool: Cannot autolaunch D-Bus without X11 $DISPLAY") + return 1 + } + name := flagValue(args, "name") + item := filepath.Join(dir, "ss-"+name) + switch args[0] { + case "lookup": + v, err := os.ReadFile(item) + if err != nil || name == "" { + return 1 + } + os.Stdout.Write(v) + return 0 + case "store": + v, _ := io.ReadAll(os.Stdin) + os.WriteFile(item, v, 0o600) + return 0 + case "clear": + os.Remove(item) + return 0 + } + return 2 +} + +// The fake Secret Manager: secrets by ID, each with labels, versions and +// IAM bindings. +type fakeGCPSecret struct { + Labels map[string]string `json:"labels"` + Versions []string `json:"versions"` + Bindings []string `json:"bindings"` +} + +func readFakeGCP(dir string) map[string]*fakeGCPSecret { + m := map[string]*fakeGCPSecret{} + if b, err := os.ReadFile(filepath.Join(dir, "gcloud.json")); err == nil { + json.Unmarshal(b, &m) + } + return m +} + +func writeFakeGCP(dir string, m map[string]*fakeGCPSecret) { + b, _ := json.Marshal(m) + os.WriteFile(filepath.Join(dir, "gcloud.json"), b, 0o600) +} + +func fakeGcloud(dir string, args []string) int { + if len(args) < 2 || args[0] != "secrets" { + return 2 + } + project := flagValue(args, "--project") + cmd := args[1] + if cmd == "versions" { + cmd += " " + args[2] + } + perm := map[string]string{"describe": "secretmanager.secrets.get", "create": "secretmanager.secrets.create", "versions add": "secretmanager.versions.add", "versions access": "secretmanager.versions.access", "list": "secretmanager.secrets.list", "delete": "secretmanager.secrets.delete", "add-iam-policy-binding": "secretmanager.secrets.setIamPolicy"}[cmd] + for _, p := range strings.Split(os.Getenv("BERTH_TEST_FAKE_DENY"), ",") { + if p != "" && p == perm { + fmt.Fprintf(os.Stderr, "ERROR: (gcloud.secrets.%s) PERMISSION_DENIED: Permission '%s' denied for resource 'projects/%s' (or it may not exist).\n", strings.ReplaceAll(cmd, " ", "."), perm, project) + return 1 + } + } + m := readFakeGCP(dir) + notFound := func(id string) int { + fmt.Fprintf(os.Stderr, "ERROR: (gcloud.secrets.%s) NOT_FOUND: Secret [projects/123456/secrets/%s] not found or has no versions.\n", strings.ReplaceAll(cmd, " ", "."), id) + return 1 + } + switch cmd { + case "describe": + s, ok := m[args[2]] + if !ok { + return notFound(args[2]) + } + json.NewEncoder(os.Stdout).Encode(map[string]any{"name": "projects/123456/secrets/" + args[2], "labels": s.Labels, "createTime": "2026-10-29T10:00:00Z"}) + case "create": + if _, ok := m[args[2]]; ok { + fmt.Fprintln(os.Stderr, "ERROR: (gcloud.secrets.create) ALREADY_EXISTS") + return 1 + } + labels := map[string]string{} + for _, kv := range strings.Split(flagValue(args, "--labels"), ",") { + k, v, _ := strings.Cut(kv, "=") + labels[k] = v + } + m[args[2]] = &fakeGCPSecret{Labels: labels} + case "versions add": + s, ok := m[args[3]] + if !ok { + return notFound(args[3]) + } + v, _ := io.ReadAll(os.Stdin) + s.Versions = append(s.Versions, string(v)) + case "versions access": + s, ok := m[flagValue(args, "--secret")] + if !ok || len(s.Versions) == 0 { + return notFound(flagValue(args, "--secret")) + } + os.Stdout.WriteString(s.Versions[len(s.Versions)-1]) + case "list": + var out []map[string]any + for id, s := range m { + if s.Labels["shipyard"] == "true" { + out = append(out, map[string]any{"name": "projects/123456/secrets/" + id, "labels": s.Labels, "createTime": "2026-10-29T10:00:00Z"}) + } + } + json.NewEncoder(os.Stdout).Encode(out) + case "delete": + if _, ok := m[args[2]]; !ok { + return notFound(args[2]) + } + delete(m, args[2]) + case "add-iam-policy-binding": + s, ok := m[args[2]] + if !ok { + return notFound(args[2]) + } + s.Bindings = append(s.Bindings, flagValue(args, "--member")+" "+flagValue(args, "--role")) + default: + return 2 + } + writeFakeGCP(dir, m) + return 0 +} + +func argvLog(t *testing.T, dir string) string { + t.Helper() + b, _ := os.ReadFile(filepath.Join(dir, "argv.log")) + return string(b) +} + +// noKeyrings keeps a test off the machine's own Keychain and Secret +// Service: the local store falls back to its encrypted file. +var noKeyrings = map[string]string{"security": "/nonexistent/security", "secret-tool": "/nonexistent/secret-tool"} + +func testStores(t *testing.T, bins map[string]string) *SecretStores { + t.Helper() + return &SecretStores{Dir: t.TempDir(), Box: "devbox", Bins: bins} +} + +func mode(t *testing.T, path string) os.FileMode { + t.Helper() + st, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return st.Mode().Perm() +} + +func TestTheEncryptedFileRoundTripsAndKeepsItsKeyApart(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + t.Setenv("XDG_RUNTIME_DIR", "") + stores := testStores(t, noKeyrings) + ctx := context.Background() + st, err := stores.Open("local", "") + if err != nil { + t.Fatal(err) + } + if st.Label() != "Encrypted file" { + t.Fatalf("a box with no Keychain or Secret Service saves to %q", st.Label()) + } + const value = "acme-sentinel-7f3a\tv2 é" + ref, err := st.Save(ctx, SecretSave{Name: "ACME_TOKEN", Value: value}) + if err != nil || ref != "box://ACME_TOKEN" { + t.Fatalf("save: %q %v", ref, err) + } + if got, err := stores.Resolve(ctx, ref); err != nil || got != value { + t.Fatalf("resolve: %q %v", got, err) + } + dir := filepath.Join(stores.Dir, "secrets") + key := filepath.Join(stores.Dir, "secret.key") + if mode(t, dir) != 0o700 || mode(t, filepath.Join(dir, "values.json")) != 0o600 || mode(t, key) != 0o600 { + t.Fatalf("modes: dir %o, values %o, key %o", mode(t, dir), mode(t, filepath.Join(dir, "values.json")), mode(t, key)) + } + if filepath.Dir(key) == dir { + t.Fatal("the key sits with the values") + } + // Nothing in the state folder holds the value in the clear. + filepath.Walk(stores.Dir, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + if b, _ := os.ReadFile(p); strings.Contains(string(b), "acme-sentinel") { + t.Errorf("%s holds the value", p) + } + } + return nil + }) + // A value can't be moved to another name: the name is bound in. + fb := &fileBackend{dir: dir, keyPath: key} + vals := fb.values() + fb.write(func(m map[string]string) { m["OTHER"] = vals["ACME_TOKEN"] }) + if _, err := fb.get(ctx, "OTHER"); err == nil { + t.Fatal("a value copied to another name decrypted") + } + // Replacing it keeps one value; deleting it leaves none. + st.Save(ctx, SecretSave{Name: "ACME_TOKEN", Value: "second"}) + if got, _ := stores.Resolve(ctx, ref); got != "second" { + t.Fatalf("after replacing: %q", got) + } + if err := st.Delete(ctx, ref); err != nil { + t.Fatal(err) + } + if _, err := stores.Resolve(ctx, ref); err == nil { + t.Fatal("a deleted secret resolved") + } +} + +func TestTheKeychainGetsTheValueOnStdinNeverAsAnArgument(t *testing.T) { + bins, dir := fakeBins(t, "security") + stores := testStores(t, bins) + ctx := context.Background() + st, _ := stores.Open("local", "") + if st.Label() != "Keychain" { + t.Fatalf("label %q", st.Label()) + } + const value = "kc-sentinel-'\"$(rm -rf)` value" + ref, err := st.Save(ctx, SecretSave{Name: "ACME_TOKEN", Value: value}) + if err != nil { + t.Fatal(err) + } + if got, err := stores.Resolve(ctx, ref); err != nil || got != value { + t.Fatalf("resolve: %q %v", got, err) + } + log := argvLog(t, dir) + if strings.Contains(log, "kc-sentinel") || strings.Contains(log, hex.EncodeToString([]byte(value))) { + t.Fatalf("the value reached security's arguments:\n%s", log) + } + if !strings.Contains(log, "-i\n") { + t.Fatalf("security was not run interactively:\n%s", log) + } + if b, l := st.(*localStore).Detail(ref); b != backendKeychain || l != "Keychain" { + t.Fatalf("detail %q %q", b, l) + } + if err := st.Delete(ctx, ref); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "kc-ACME_TOKEN")); !os.IsNotExist(err) { + t.Fatal("the Keychain item is still there") + } +} + +func TestASecretServiceIsUsedOnlyWhenOneAnswers(t *testing.T) { + ctx := context.Background() + bins, dir := fakeBins(t, "secret-tool") + bins["security"] = noKeyrings["security"] + // A bus that answers. + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus") + stores := testStores(t, bins) + st, _ := stores.Open("local", "") + if st.Label() != "Secret Service" { + t.Fatalf("with a Secret Service: %q", st.Label()) + } + ref, _ := st.Save(ctx, SecretSave{Name: "ACME_TOKEN", Value: "ss-sentinel"}) + if got, _ := stores.Resolve(ctx, ref); got != "ss-sentinel" { + t.Fatalf("resolve %q", got) + } + if strings.Contains(argvLog(t, dir), "ss-sentinel") { + t.Fatal("the value reached secret-tool's arguments") + } + // A bus with no service: the encrypted file. + t.Setenv("BERTH_TEST_FAKE_MODE", "nobus") + if l := testStores(t, bins); true { + st, _ := l.Open("local", "") + if st.Label() != "Encrypted file" { + t.Fatalf("with no service on the bus: %q", st.Label()) + } + } + // No bus at all: the encrypted file, without asking. + t.Setenv("BERTH_TEST_FAKE_MODE", "") + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + st2, _ := testStores(t, bins).Open("local", "") + if st2.Label() != "Encrypted file" { + t.Fatalf("with no bus: %q", st2.Label()) + } + // What the Secret Service saved still reads from it after it is gone + // from the bus: the record says where. + if got, err := stores.Resolve(ctx, ref); err != nil || got != "ss-sentinel" { + t.Fatalf("resolve after: %q %v", got, err) + } +} + +func TestExpiredSecretsReadAsGoneAndAreSwept(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + t.Setenv("XDG_RUNTIME_DIR", "") + ctx := context.Background() + now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) + stores := testStores(t, noKeyrings) + stores.Now = func() time.Time { return now } + st, _ := stores.Open("local", "") + for _, n := range []string{"SHORT", "LONG"} { + ref, err := st.Save(ctx, SecretSave{Name: n, Value: n + "-v"}) + if err != nil { + t.Fatal(err) + } + m := SecretMeta{Name: n, Ref: ref, Store: "local", Created: now} + if n == "SHORT" { + exp := now.Add(time.Hour) + m.Expires = &exp + } + stores.Index().Put(m) + } + now = now.Add(61 * time.Minute) + if _, err := stores.Resolve(ctx, "box://SHORT"); err == nil || !errors.Is(err, errSecretExpired) { + t.Fatalf("an expired secret resolved: %v", err) + } + if gone := stores.Sweep(ctx); len(gone) != 1 || gone[0] != "SHORT" { + t.Fatalf("swept %v", gone) + } + if _, ok := stores.Index().Get("box://SHORT"); ok { + t.Fatal("the sweep left it in the index") + } + if _, ok := st.(*localStore).records()["SHORT"]; ok { + t.Fatal("the sweep left it in the store") + } + if v, err := stores.Resolve(ctx, "box://LONG"); err != nil || v != "LONG-v" { + t.Fatalf("a secret with no expiry: %q %v", v, err) + } +} + +func TestReferencesToSavedSecrets(t *testing.T) { + for ref, ok := range map[string]bool{ + "box://ACME_TOKEN": true, + "box://acme": false, + "box://": false, + "box://A/B": false, + "gcp://projects/acme-staging/secrets/ACME_TOKEN": true, + "gcp://projects/acme-staging/secrets/ACME_TOKEN/versions/3": true, + "gcp://projects/acme-staging/secrets/ACME_TOKEN/versions/latest": true, + "gcp://projects/Acme/secrets/X": false, + "gcp://acme/X": false, + } { + if !IsSecretRef(ref) { + t.Errorf("%s is not seen as a reference", ref) + } + if err := ValidateSecretRef(ref); (err == nil) != ok { + t.Errorf("%s: %v", ref, err) + } + } + if IsSecretRef("vault://x") { + t.Error("an unknown scheme is a reference") + } + // A config can name them like any other reference. + if err := (RepoConfig{Env: map[string]string{"T": "box://ACME_TOKEN"}}).validate(); err != nil { + t.Fatal(err) + } + if err := (RepoConfig{Env: map[string]string{"T": "box://lower"}}).validate(); err == nil { + t.Fatal("a bad box:// reference passed") + } +} + +func TestTheStoreComesFromTheRequestThenTheProjectThenTheBox(t *testing.T) { + boxDefault := SecretStoreChoice{Store: "gcp", GCPProject: "acme-box"} + project := SecretStoreChoice{Store: "local"} + for _, c := range []struct { + request string + project, box SecretStoreChoice + wantID, wantProject string + }{ + {"", SecretStoreChoice{}, SecretStoreChoice{}, "local", ""}, + {"", SecretStoreChoice{}, boxDefault, "gcp", "acme-box"}, + {"", project, boxDefault, "local", ""}, + {"gcp", project, boxDefault, "gcp", "acme-box"}, + {"gcp", SecretStoreChoice{GCPProject: "acme-proj"}, boxDefault, "gcp", "acme-proj"}, + {"local", SecretStoreChoice{Store: "gcp", GCPProject: "acme-proj"}, boxDefault, "local", ""}, + } { + id, where := Choose(c.request, c.project, c.box) + if id != c.wantID || where != c.wantProject { + t.Errorf("Choose(%q, %+v, %+v) = %s %s, want %s %s", c.request, c.project, c.box, id, where, c.wantID, c.wantProject) + } + } +} + +func TestTheGCPStoreLabelsWhatItMakesAndTouchesNothingElse(t *testing.T) { + bins, dir := fakeBins(t, "gcloud") + stores := testStores(t, bins) + ctx := context.Background() + st, err := stores.Open("gcp", "acme-staging") + if err != nil { + t.Fatal(err) + } + if _, err := stores.Open("gcp", ""); err == nil { + t.Fatal("gcp opened with no project") + } + ref, err := st.Save(ctx, SecretSave{Name: "ACME_TOKEN", Value: "gcp-sentinel-1", Environment: "staging", Location: "Acme Shop", Grant: "deployer@acme-staging.iam.gserviceaccount.com"}) + if err != nil { + t.Fatal(err) + } + if ref != "gcp://projects/acme-staging/secrets/ACME_TOKEN" { + t.Fatalf("ref %q", ref) + } + s := readFakeGCP(dir)["ACME_TOKEN"] + want := map[string]string{"shipyard": "true", "shipyard-environment": "staging", "shipyard-location": "acme-shop", "shipyard-box": "devbox"} + for k, v := range want { + if s.Labels[k] != v { + t.Errorf("label %s = %q, want %q (%v)", k, s.Labels[k], v, s.Labels) + } + } + if len(s.Versions) != 1 || len(s.Bindings) != 1 || s.Bindings[0] != "serviceAccount:deployer@acme-staging.iam.gserviceaccount.com roles/secretmanager.secretAccessor" { + t.Fatalf("secret = %+v", s) + } + log := argvLog(t, dir) + if strings.Contains(log, "gcp-sentinel") { + t.Fatal("the value reached gcloud's arguments") + } + // The binding is on that one secret: no project-level IAM command ran. + if strings.Contains(log, "projects add-iam-policy-binding") || !strings.Contains(log, "secrets add-iam-policy-binding ACME_TOKEN") { + t.Fatalf("gcloud ran:\n%s", log) + } + if v, err := stores.Resolve(ctx, ref); err != nil || v != "gcp-sentinel-1" { + t.Fatalf("resolve %q %v", v, err) + } + // A labelled one gets a new version. + if _, err := st.Save(ctx, SecretSave{Name: "ACME_TOKEN", Value: "gcp-sentinel-2"}); err != nil { + t.Fatal(err) + } + if s := readFakeGCP(dir)["ACME_TOKEN"]; len(s.Versions) != 2 { + t.Fatalf("versions %v", s.Versions) + } + if v, _ := stores.Resolve(ctx, ref); v != "gcp-sentinel-2" { + t.Fatalf("latest %q", v) + } + // One Shipyard didn't make is refused, and left as it was. + m := readFakeGCP(dir) + m["THEIRS"] = &fakeGCPSecret{Labels: map[string]string{"team": "billing"}, Versions: []string{"keep"}} + writeFakeGCP(dir, m) + if _, err := st.Save(ctx, SecretSave{Name: "THEIRS", Value: "x"}); err == nil || !strings.Contains(err.Error(), "didn't make") { + t.Fatalf("an unlabelled secret: %v", err) + } + if err := st.Delete(ctx, "gcp://projects/acme-staging/secrets/THEIRS"); err == nil { + t.Fatal("an unlabelled secret was deleted") + } + if s := readFakeGCP(dir)["THEIRS"]; len(s.Versions) != 1 { + t.Fatalf("an unlabelled secret changed: %+v", s) + } + // Listing finds only the labelled ones. + metas, err := st.List(ctx) + if err != nil || len(metas) != 1 || metas[0].Ref != ref || metas[0].Where != "acme-staging" || metas[0].Environment != "staging" { + t.Fatalf("list %+v %v", metas, err) + } + if err := st.Delete(ctx, ref); err != nil { + t.Fatal(err) + } + if _, ok := readFakeGCP(dir)["ACME_TOKEN"]; ok { + t.Fatal("not deleted") + } +} + +func TestTheGCPStoreNamesTheMissingPermission(t *testing.T) { + bins, _ := fakeBins(t, "gcloud") + stores := testStores(t, bins) + ctx := context.Background() + st, _ := stores.Open("gcp", "acme-staging") + for perm, save := range map[string]SecretSave{ + "secretmanager.secrets.create": {Name: "A_KEY", Value: "v"}, + "secretmanager.versions.add": {Name: "B_KEY", Value: "v"}, + "secretmanager.secrets.setIamPolicy": {Name: "C_KEY", Value: "v", Grant: "ci@acme-staging.iam.gserviceaccount.com"}, + } { + t.Setenv("BERTH_TEST_FAKE_DENY", perm) + _, err := st.Save(ctx, save) + if err == nil || !strings.Contains(err.Error(), "missing permission "+perm+" on project acme-staging") { + t.Errorf("denied %s: %v", perm, err) + } + } + t.Setenv("BERTH_TEST_FAKE_DENY", "secretmanager.secrets.list") + if _, err := st.List(ctx); err == nil || !strings.Contains(err.Error(), "secretmanager.secrets.list") { + t.Errorf("list: %v", err) + } +} diff --git a/internal/box/sessions.go b/internal/box/sessions.go index 8f068bb00..7451726b3 100644 --- a/internal/box/sessions.go +++ b/internal/box/sessions.go @@ -69,6 +69,9 @@ type Session struct { // its last message: the reply box answers it, and its screen shows its // prompt, with no question there to read options from. SoftWait bool `json:"soft_wait,omitempty"` + // SecretRequest is the secret the agent asked the person for, while + // the ask waits (secretrequests.go): never a value. + SecretRequest *SecretRequest `json:"secret_request,omitempty"` // Service is set on a worktree service's own terminal (a service with // "terminal": true): the service's name, kept as @berth_service. It is // never an agent, whatever it runs. diff --git a/internal/box/tasks.go b/internal/box/tasks.go index 01b621585..413ff6736 100644 --- a/internal/box/tasks.go +++ b/internal/box/tasks.go @@ -465,6 +465,17 @@ func (b *Box) enrich(ctx context.Context, all []Session) []Session { } } } + // A secret request waits on the person like a question does, but + // the agent still takes prompts: its answer comes as one. + if b.SecretRequests != nil { + if sr := b.SecretRequests.ForSession(s.Name); sr != nil { + s.SecretRequest = sr + if s.AgentState != "waiting" || s.SoftWait { + s.AgentState, s.SoftWait, s.StateSince = "waiting", true, sr.Created + s.Ask = &Ask{Tool: "SecretRequest", Message: "Asks for the secret " + sr.Name} + } + } + } } return all } diff --git a/internal/boxcmd/boxcmd.go b/internal/boxcmd/boxcmd.go index b47c1e95e..5be533767 100644 --- a/internal/boxcmd/boxcmd.go +++ b/internal/boxcmd/boxcmd.go @@ -130,7 +130,8 @@ var usageSections = []struct { {"%[1]s skills%[3]s [list|install|uninstall] [SKILL...] [--agent claude|codex|all]\n [--target user|project] [--location LOC] [--commit]", "Teach Claude Code and Codex to use berth"}, }}, {"Secrets", [][2]string{ - {"%[1]s secret test%[3]s REF", "Check this box can resolve op://vault/item/field or env://NAME\n(prints the value's length, never the value)"}, + {"%[1]s secret test%[3]s REF", "Check this box can resolve op://vault/item/field, env://NAME or box://NAME\n(prints the value's length, never the value)"}, + {"%[1]s secret request%[3]s NAME --reason WHY [--new] [--store local|gcp]\n [--environment staging|production] [--grant SA_EMAIL] [--ttl 1h|24h|7d] [--wait 90s]", "Ask the person for a secret through a card in Shipyard; the agent gets\na reference (box://NAME), never the value"}, }}, {"Events", [][2]string{ {"%[1]s emit%[3]s TYPE [key=value...] [--origin TOOL]", "Announce an event, e.g. agent.finished"}, @@ -251,6 +252,8 @@ func Run(ctx context.Context, c *box.Client, args []string, out io.Writer) error return fmt.Errorf("could not resolve %s: %s", pos[0], res.Error) } return nil + case "secret request": + return secretRequest(ctx, c, rest, out) case "preview": return preview(ctx, c, rest, out) case "runs": diff --git a/internal/boxcmd/boxcmd_test.go b/internal/boxcmd/boxcmd_test.go index 223a5eaa8..53c0a631e 100644 --- a/internal/boxcmd/boxcmd_test.go +++ b/internal/boxcmd/boxcmd_test.go @@ -413,3 +413,23 @@ func TestBrowserTabsFromTheCommandLine(t *testing.T) { t.Fatal("a tab named \"a b\"") } } + +func TestSecretRequestAsksForTheSessionItRunsIn(t *testing.T) { + t.Setenv("BERTH_SESSION", "") + rec := &recorder{reply: `{}`} + if err := Run(context.Background(), &box.Client{Doer: rec}, []string{"secret", "request", "ACME_TOKEN", "--reason", "deploy"}, io.Discard); err == nil || !strings.Contains(err.Error(), "BERTH_SESSION") { + t.Fatalf("outside a session: %v", err) + } + t.Setenv("BERTH_SESSION", "shop-fix-claude") + r, out := run(t, `{"id":"sr_1","name":"ACME_TOKEN","status":"pending"}`, "secret", "request", "ACME_TOKEN", "--reason", "deploy the acme app", "--environment", "staging", "--ttl", "24h", "--new") + if r.method != "POST" || r.path != "/v1/secrets/requests" || r.body["name"] != "ACME_TOKEN" || r.body["reason"] != "deploy the acme app" || r.body["environment"] != "staging" || r.body["ttl"] != "24h" || r.body["new"] != true { + t.Fatalf("request %s %s %v", r.method, r.path, r.body) + } + if !strings.Contains(out, "sr_1") || !strings.Contains(out, "") { + t.Fatalf("out = %q", out) + } + _, out = run(t, `{"name":"ACME_TOKEN","status":"saved","ref":"box://ACME_TOKEN","reused":true}`, "secret", "request", "ACME_TOKEN", "--reason", "x") + if !strings.Contains(out, "saved already") || !strings.Contains(out, "berthd secret exec --env ACME_TOKEN=box://ACME_TOKEN") { + t.Fatalf("reused: %q", out) + } +} diff --git a/internal/boxcmd/secretcmd.go b/internal/boxcmd/secretcmd.go new file mode 100644 index 000000000..5f23014b1 --- /dev/null +++ b/internal/boxcmd/secretcmd.go @@ -0,0 +1,79 @@ +package boxcmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/cosscom/shipyard/internal/box" +) + +// secretRequest asks the person for a secret through a card in the app, +// for the agent session it runs in. It never waits long: the answer comes +// back as a , with --wait at most 90 seconds. +func secretRequest(ctx context.Context, c *box.Client, args []string, out io.Writer) error { + fs, asJSON := flags(args) + var req box.SecretRequestBody + fs.StringVar(&req.Reason, "reason", "", "why it is needed; the person reads it on the card") + fs.BoolVar(&req.New, "new", false, "ask for a new value even when one is saved") + fs.StringVar(&req.Store, "store", "", "where to save it: local or gcp (default: the project's, else the box's)") + fs.StringVar(&req.Environment, "environment", "", "staging or production") + fs.StringVar(&req.Grant, "grant", "", "a service account to give read access to this one secret (gcp)") + fs.StringVar(&req.TTL, "ttl", "", "suggest a lifetime: 1h, 24h or 7d (default: none)") + wait := fs.Duration("wait", 0, "wait up to this long for the answer, at most 90s") + pos, err := parse(fs, args) + if err != nil || len(pos) != 1 { + return usageErr("secret request NAME --reason WHY [--new] [--store local|gcp] [--environment staging|production] [--grant SA_EMAIL] [--ttl 1h|24h|7d] [--wait 90s]") + } + req.Name = pos[0] + session := os.Getenv("BERTH_SESSION") + if session == "" { + return errors.New("run this in an agent session on the box: BERTH_SESSION names the session that hears the answer") + } + sr, err := c.WithCaller(session).RequestSecret(ctx, req) + if err != nil { + return err + } + if *wait > 90*time.Second { + *wait = 90 * time.Second + } + if sr.Status == "pending" && *wait > 0 { + deadline := time.Now().Add(*wait) + for sr.Status == "pending" && time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + if now, err := c.SecretRequest(ctx, sr.ID); err == nil { + sr = now + } + } + } + if err := show(out, *asJSON, sr, func() { + switch sr.Status { + case "saved": + if sr.Reused { + fmt.Fprintf(out, "%s is saved already. Its reference is %s.\n", sr.Name, sr.Ref) + } else { + fmt.Fprintf(out, "The person saved %s. Its reference is %s.\n", sr.Name, sr.Ref) + } + fmt.Fprintf(out, "Use it in one command: berthd secret exec --env %s=%s -- COMMAND\n(--file %s=%s for a file holding it). Never print the value.\n", sr.Name, sr.Ref, sr.Name, sr.Ref) + case "pending": + fmt.Fprintf(out, "Asked the person for %s (request %s); a card waits in Shipyard.\n", sr.Name, sr.ID) + fmt.Fprintln(out, "Shipyard tells you when they answer: a with the reference arrives at your next idle. End your turn rather than waiting.") + } + }); err != nil { + return err + } + switch sr.Status { + case "declined": + return fmt.Errorf("the person declined to give %s", sr.Name) + case "timed_out": + return fmt.Errorf("nobody answered the request for %s", sr.Name) + } + return nil +} diff --git a/internal/integrations/skills/berth/SKILL.md b/internal/integrations/skills/berth/SKILL.md index 5a2770db5..ab847998a 100644 --- a/internal/integrations/skills/berth/SKILL.md +++ b/internal/integrations/skills/berth/SKILL.md @@ -116,6 +116,29 @@ then shows `repo_trust.state` as `untrusted` or `changed`. Never trust it yourself (`--trust`); tell the user what it wants to run and let them decide in the app or with `berth location config BOX/LOC --trust HASH`. +## When you need a secret + +Never ask the user to paste a key or a token into the chat. Ask for it by +name, with why; a masked card shows in Shipyard: + +```sh +berthd secret request ACME_TOKEN --reason "Deploy the acme app to staging" # or the berth_secret_request tool +``` + +It returns at once. End your turn: a `` brings back a +reference such as `box://ACME_TOKEN`, never the value (one already saved +comes back at once). Use it in one command, and never print it: + +```sh +berthd secret exec --env ACME_TOKEN=box://ACME_TOKEN -- ./deploy.sh +berthd secret exec --file GOOGLE_APPLICATION_CREDENTIALS=box://ACME_SA_KEY -- gcloud … # a temp file, removed after +``` + +`--new` asks for a replacement, `--environment production` marks it so, +`--store gcp` saves to Secret Manager (`--grant SA_EMAIL` gives one service +account read access to that secret alone). If the user declines, carry on +without it or ask what they want. + ## Review links for the PRs you open When a pull request you open from a berth worktree changes something a diff --git a/internal/mcpserver/mcp.go b/internal/mcpserver/mcp.go index 1e3b1fff8..a9fe5209f 100644 --- a/internal/mcpserver/mcp.go +++ b/internal/mcpserver/mcp.go @@ -142,6 +142,7 @@ var Tools = []Tool{ {Name: "berth_run_start", Description: "Start a durable run from a template (loop, review, handoff, broadcast, attempts, fix-ci, address-review). Returns the run ID." + told, InputSchema: obj(with(map[string]any{"template": str("template name"), "params": map[string]any{"type": "object", "description": "template parameters"}, "idem": str("idempotency key")}), "template", "params"), call: runStart}, {Name: "berth_run_get", Description: "A run's status, its open gate, current step and attempts, in brief.", InputSchema: obj(map[string]any{"run": str("run ID")}, "run"), call: runGet}, {Name: "berth_run_cancel", Description: "Cancel a run.", InputSchema: obj(map[string]any{"run": str("run ID")}, "run"), call: runCancel}, + {Name: "berth_secret_request", Description: "Ask the person for a secret (an API key, a token) through a masked card in Shipyard, instead of having it pasted into the chat. Returns at once: a saved one's reference, or a request ID; when they answer you get a with a reference like box://NAME, never the value. Use it in one command with `berthd secret exec --env VAR=REF -- COMMAND` (or --file VAR=REF for a file); never print it.", InputSchema: obj(map[string]any{"name": str("the secret's name, an environment variable's: ACME_TOKEN"), "reason": str("why you need it; shown to the person verbatim"), "new": map[string]any{"type": "boolean", "description": "ask for a new value even when one is saved (a replacement)"}, "store": str("local or gcp (default: the project's, else the box's)"), "environment": str("staging or production"), "grant": str("gcp: a service account to give read access to this one secret"), "ttl": str("suggest a lifetime: 1h, 24h or 7d (default none)"), "wait": num("seconds to wait for the answer, at most 90 (default 0)")}, "name", "reason"), call: secretRequest}, {Name: "berth_attempts", Description: "Try one task N ways: each agent in its own worktree, verified by check, judged, then a person picks. Returns the run ID." + told, InputSchema: obj(with(map[string]any{"location": str("location"), "name": str("worktree name prefix"), "prompt": str("the task"), "agents": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "agent per attempt, e.g. [\"claude\",\"codex\"]"}, "check": str("command that must pass")}), "location", "name", "prompt", "agents"), call: attempts}, } @@ -197,7 +198,7 @@ func (s *Server) handle(ctx context.Context, req request) (any, *rpcError) { "protocolVersion": v, "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "berth", "version": version.Version}, - "instructions": "Tools for this box's agents, worktrees and durable runs. Never block: wait_turn takes at most 90 s and returns a cursor. Work you start (task_new, send, exec, run_start, attempts) reports back: a message from Shipyard, not the user, arrives at your next idle when it ends, needs a person or reaches a gate. So end your turn rather than polling.", + "instructions": "Tools for this box's agents, worktrees and durable runs. Never block: wait_turn takes at most 90 s and returns a cursor. Work you start (task_new, send, exec, run_start, attempts, secret_request) reports back: a message from Shipyard, not the user, arrives at your next idle when it ends, needs a person or reaches a gate. So end your turn rather than polling.", }, nil case "ping": return map[string]any{}, nil @@ -442,6 +443,45 @@ func runCancel(ctx context.Context, s *Server, a map[string]any) (any, error) { return map[string]bool{"cancelled": true}, nil } +func secretRequest(ctx context.Context, s *Server, a map[string]any) (any, error) { + if err := need(a, "name", "reason"); err != nil { + return nil, err + } + who := s.caller(ctx) + if who == "" { + return nil, errors.New("a secret is requested by an agent session on the box, and this one's isn't known (BERTH_SESSION)") + } + nw, _ := a["new"].(bool) + c := s.Box.WithCaller(who) + sr, err := c.RequestSecret(ctx, box.SecretRequestBody{Name: arg(a, "name"), Reason: arg(a, "reason"), New: nw, Store: arg(a, "store"), Environment: arg(a, "environment"), Grant: arg(a, "grant"), TTL: arg(a, "ttl")}) + if err != nil { + return nil, err + } + deadline := time.Now().Add(seconds(a, "wait", 0, 90)) + for sr.Status == "pending" && time.Now().Before(deadline) { + select { + case <-ctx.Done(): + deadline = time.Now() + continue + case <-time.After(time.Second): + } + if now, err := s.Box.SecretRequest(ctx, sr.ID); err == nil { + sr = now + } + } + out := map[string]any{"name": sr.Name, "status": sr.Status} + switch sr.Status { + case "pending": + out["request"], out["notify"] = sr.ID, true + case "saved": + out["ref"] = sr.Ref + if sr.Reused { + out["reused"] = true + } + } + return out, nil +} + func attempts(ctx context.Context, s *Server, a map[string]any) (any, error) { if err := need(a, "location", "name", "prompt"); err != nil { return nil, err diff --git a/internal/mcpserver/mcp_notify_test.go b/internal/mcpserver/mcp_notify_test.go index fee45037d..03ded1625 100644 --- a/internal/mcpserver/mcp_notify_test.go +++ b/internal/mcpserver/mcp_notify_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/cosscom/shipyard/internal/box" ) @@ -48,6 +49,8 @@ func (f *callerBox) DoWithHeader(ctx context.Context, method, path string, body w.WriteString(`{"forgot":1}`) case strings.HasPrefix(path, "/v1/runs/"): w.WriteString(`{"id":"r_x","template":"exec","status":"running","steps":[]}`) + case path == "/v1/secrets/requests": + w.WriteString(`{"id":"sr_1","name":"ACME_TOKEN","session":"lead-claude","store":"local","status":"pending"}`) default: w.WriteHeader(404) w.WriteString(`{"error":"no"}`) @@ -168,3 +171,27 @@ func TestAnAgentWithoutItsEnvironmentIsFoundByItsProcess(t *testing.T) { t.Fatalf("named %q outside a session", who) } } + +// A secret request returns at once with its ID; the answer comes back as a +// . +func TestASecretRequestReturnsAtOnce(t *testing.T) { + f := &callerBox{} + s := &Server{Box: box.NewClient(f), Caller: "lead-claude"} + start := time.Now() + res := rpc(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"berth_secret_request","arguments":{"name":"ACME_TOKEN","reason":"deploy the acme app"}}}`) + if time.Since(start) > 2*time.Second { + t.Fatalf("it waited %s", time.Since(start)) + } + text := res[0]["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + if !strings.Contains(text, `"request":"sr_1"`) || !strings.Contains(text, `"status":"pending"`) || !strings.Contains(text, `"notify":true`) { + t.Fatalf("result %s", text) + } + if who, ok := f.callerOf("POST /v1/secrets/requests"); !ok || who != "lead-claude" { + t.Fatalf("the request named %q (%v)", who, f.calls) + } + // Without a session it can't be asked. + res = rpc(t, &Server{Box: box.NewClient(&callerBox{})}, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"berth_secret_request","arguments":{"name":"ACME_TOKEN","reason":"x"}}}`) + if res[0]["result"].(map[string]any)["isError"] != true { + t.Fatalf("a request with no session: %v", res[0]) + } +} diff --git a/internal/transcript/report.go b/internal/transcript/report.go index 58d1b0ca8..28eb2f09e 100644 --- a/internal/transcript/report.go +++ b/internal/transcript/report.go @@ -13,7 +13,7 @@ import ( // Report is one piece of work a notification reports on. type Report struct { - // Kind is task, turn or run. + // Kind is task, turn, run or secret. Kind string `json:"kind"` Session string `json:"session,omitempty"` Run string `json:"run,omitempty"` From 4ef4ad9b313c7730c8b5a962ef29c7bd89001c10 Mon Sep 17 00:00:00 2001 From: Stener1 Date: Fri, 30 Oct 2026 05:59:28 +0000 Subject: [PATCH 2/3] fix(box): a secret being saved can't be declined or timed out, and an 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 --- .../conversation/secret-request-card.tsx | 9 +- app/src/lib/secret-request.test.ts | 7 + app/src/lib/secret-request.ts | 3 + app/src/views/settings/secrets-section.tsx | 15 +- cmd/berthd/secret.go | 22 +-- internal/box/client.go | 17 ++ internal/box/notify.go | 6 +- internal/box/repoconfig.go | 19 ++ internal/box/secretrequests.go | 163 +++++++++++++----- internal/box/secretrequests_test.go | 90 ++++++++++ internal/box/secrets.go | 7 + internal/box/secretstore.go | 84 ++++++--- internal/box/secretstore_gcp.go | 6 - internal/box/secretstore_local.go | 23 ++- internal/box/secretstore_test.go | 9 +- internal/box/tasks.go | 16 +- internal/boxcmd/secretcmd.go | 18 +- internal/mcpserver/mcp.go | 15 +- 18 files changed, 380 insertions(+), 149 deletions(-) diff --git a/app/src/components/conversation/secret-request-card.tsx b/app/src/components/conversation/secret-request-card.tsx index bafef7768..e1ad72676 100644 --- a/app/src/components/conversation/secret-request-card.tsx +++ b/app/src/components/conversation/secret-request-card.tsx @@ -10,10 +10,13 @@ import { boxApi, type SecretExpiry, type SecretRequest } from "@/lib/api"; import { sessionName } from "@/lib/derive"; import { plainError } from "@/lib/errors"; import { + busy, canDecline, canSave, cardReducer, cardTitle, + done, + environmentLabel, errorLine, expiryOptions, grantLine, @@ -55,7 +58,7 @@ export function SecretRequestCard({ box, request, compact }: { box: string; requ const grant = grantLine(request); const replaced = replacedLine(request); const warn = prod && "text-warning-foreground"; - const inFlight = state.phase === "saving" || state.phase === "declining"; + const inFlight = busy(state); // A refusal shows above the buttons: both brought into view, as the // chat doesn't move for it. const said = useRef(null); @@ -92,7 +95,7 @@ export function SecretRequestCard({ box, request, compact }: { box: string; requ // Over: the box drops the request, and this line goes with it a moment // later. Never the field again. - if (state.phase === "saved" || state.phase === "declined") { + if (done(state)) { const saved = state.phase === "saved"; return (
@@ -156,7 +159,7 @@ export function SecretRequestCard({ box, request, compact }: { box: string; requ <>
Environment
- {prod ? "Production" : request.environment === "staging" ? "Staging" : request.environment} + {environmentLabel(request)}
)} diff --git a/app/src/lib/secret-request.test.ts b/app/src/lib/secret-request.test.ts index 76d102abf..5643b1a13 100644 --- a/app/src/lib/secret-request.test.ts +++ b/app/src/lib/secret-request.test.ts @@ -11,6 +11,7 @@ import { type CardState, cardTitle, deleteConfirm, + environmentLabel, errorLine, EXPIRY_OPTIONS, expiresLine, @@ -180,3 +181,9 @@ test("a saved secret's expiry reads in words", () => { assert.equal(expiresLine("2026-11-05T10:00:00Z", now), "In 7d"); assert.equal(expiresLine("2026-10-29T09:00:00Z", now), "Expired"); }); + +test("an environment reads the same on the card and in Settings", () => { + assert.equal(environmentLabel({ environment: "production" }), "Production"); + assert.equal(environmentLabel({ environment: "staging" }), "Staging"); + assert.equal(environmentLabel({}), ""); +}); diff --git a/app/src/lib/secret-request.ts b/app/src/lib/secret-request.ts index 4d2f86e81..12324d91e 100644 --- a/app/src/lib/secret-request.ts +++ b/app/src/lib/secret-request.ts @@ -44,6 +44,9 @@ export function pendingSecrets(sessions: Session[] | undefined): SecretRequest[] export const isProduction = (r: { environment?: string }): boolean => r.environment === "production"; +// environmentLabel is an environment as the card and Settings write it. +export const environmentLabel = (r: { environment?: string }): string => (isProduction(r) ? "Production" : r.environment === "staging" ? "Staging" : (r.environment ?? "")); + // expiryOptions are the card's choices, or none: production is always no // expiry, so it has no picker. export const expiryOptions = (r: SecretRequest): typeof EXPIRY_OPTIONS | undefined => (isProduction(r) ? undefined : EXPIRY_OPTIONS); diff --git a/app/src/views/settings/secrets-section.tsx b/app/src/views/settings/secrets-section.tsx index 843618e8d..2a72f23ad 100644 --- a/app/src/views/settings/secrets-section.tsx +++ b/app/src/views/settings/secrets-section.tsx @@ -12,7 +12,7 @@ import { boxApi, type SecretMeta, type SecretSettings, type SecretsList, type Se import { useEventLog } from "@/lib/events"; import { sessionName } from "@/lib/derive"; import { ago, errorMessage } from "@/lib/format"; -import { deleteConfirm, expiresLine, isProduction, needsTypedName, storeLine } from "@/lib/secret-request"; +import { deleteConfirm, environmentLabel, expiresLine, isProduction, needsTypedName, storeLine } from "@/lib/secret-request"; import { NONE, useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; import { Code, SettingsGroup, SettingsPage, SettingsRow } from "@/views/settings/rows"; @@ -107,7 +107,7 @@ function SecretRow({ box, s, onDeleted }: { box: string; s: SecretMeta; onDelete {s.name} {s.environment && ( - {prod ? "Production" : s.environment === "staging" ? "Staging" : s.environment} + {environmentLabel(s)} )}
@@ -217,7 +217,7 @@ function WhereSaved({ box, stores, settings, onSaved }: { box: string; stores: S if (!client) return; setBusy(true); try { - await boxApi.putSecretSettings(client, box, clean(draft)); + await boxApi.putSecretSettings(client, box, cleared(clean(draft), settings)); toastManager.add({ type: "success", title: `Saved where ${box} keeps secrets` }); onSaved(); } catch (err) { @@ -254,6 +254,15 @@ function WhereSaved({ box, stores, settings, onSaved }: { box: string; stores: S ); } +// cleared adds an empty choice for each project that had one of its own +// and is back on the box's default: the box only changes the projects a +// save names. +function cleared(out: SecretSettings, was: SecretSettings): SecretSettings { + const locations = { ...out.locations }; + for (const name of Object.keys(was.locations ?? {})) if (!locations[name]) locations[name] = {}; + return Object.keys(locations).length ? { ...out, locations } : out; +} + // clean drops a project's empty choice, so "Box default" is no entry, and // a GCP project where the store isn't GCP. function clean(s: SecretSettings): SecretSettings { diff --git a/cmd/berthd/secret.go b/cmd/berthd/secret.go index fc3c6dc8a..3bec92b1b 100644 --- a/cmd/berthd/secret.go +++ b/cmd/berthd/secret.go @@ -10,7 +10,6 @@ import ( "os/exec" "os/signal" "path/filepath" - "regexp" "sort" "strings" "syscall" @@ -59,7 +58,9 @@ func secretExec(b boxHome, args []string) error { os.Unsetenv(box.SecretVarsEnv) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - values, failed := b.boxSecrets().ResolveAll(ctx, refs, nil) + // One reader for both rounds: a reference in both is read once. + secrets := b.boxSecrets() + values, failed := secrets.ResolveAll(ctx, refs, nil) rep := box.SecretReport{Location: os.Getenv("BERTH_LOCATION"), Name: os.Getenv("BERTH_WORKTREE_NAME"), Path: os.Getenv("BERTH_WORKTREE_PATH")} for k, v := range values { os.Setenv(k, v) @@ -79,7 +80,7 @@ func secretExec(b boxHome, args []string) error { for k, ref := range fileRefs { asked[k] = ref } - got, bad := b.boxSecrets().ResolveAll(ctx, asked, nil) + got, bad := secrets.ResolveAll(ctx, asked, nil) if len(bad) > 0 { var msgs []string for k, err := range bad { @@ -119,7 +120,7 @@ func (r *refFlags) String() string { return "" } func (r *refFlags) Set(v string) error { k, ref, ok := strings.Cut(v, "=") - if !ok || !envVarName.MatchString(k) { + if !ok || !box.ValidEnvName(k) { return fmt.Errorf("%q is not VAR=REF", v) } if err := box.ValidateSecretRef(ref); err != nil { @@ -132,8 +133,6 @@ func (r *refFlags) Set(v string) error { return nil } -var envVarName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - // secretFileDir is where --file writes: a folder in memory when the box // has one, so the value never reaches a disk. func secretFileDir() string { @@ -210,16 +209,11 @@ func reportSecrets(socket string, rep box.SecretReport) { func (b boxHome) opSessionFile() string { return filepath.Join(b.dir, "op-session") } // boxSecrets is how this box reads secrets, outside berthd serve too. -func (b boxHome) boxSecrets() *box.Secrets { - host, _ := os.Hostname() - return &box.Secrets{SessionFile: b.opSessionFile(), Box: host, Stores: b.secretStores()} -} - -// secretStores are the secrets saved through secret cards, which berthd +// Its stores are the secrets saved through secret cards, which berthd // serve saves and any process on the box reads. -func (b boxHome) secretStores() *box.SecretStores { +func (b boxHome) boxSecrets() *box.Secrets { host, _ := os.Hostname() - return &box.SecretStores{Dir: b.dir, Box: host} + return &box.Secrets{SessionFile: b.opSessionFile(), Box: host, Stores: &box.SecretStores{Dir: b.dir, Box: host}} } // secretSignin signs the box's op in to 1Password, in the terminal it runs diff --git a/internal/box/client.go b/internal/box/client.go index 94fb6cc79..f31429598 100644 --- a/internal/box/client.go +++ b/internal/box/client.go @@ -388,6 +388,23 @@ func (c *Client) SecretRequest(ctx context.Context, id string) (out SecretReques return out, c.call(ctx, http.MethodGet, "/v1/secrets/requests/"+url.PathEscape(id), nil, &out) } +// AwaitSecretRequest waits up to wait (at most 90 seconds) for a pending +// request to be answered, and gives it as it then stands. +func (c *Client) AwaitSecretRequest(ctx context.Context, sr SecretRequest, wait time.Duration) (SecretRequest, error) { + deadline := time.Now().Add(min(wait, 90*time.Second)) + for sr.Status == "pending" && time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return sr, ctx.Err() + case <-time.After(time.Second): + } + if now, err := c.SecretRequest(ctx, sr.ID); err == nil { + sr = now + } + } + return sr, nil +} + func (c *Client) Emit(ctx context.Context, typ string, data map[string]any) error { return c.call(ctx, http.MethodPost, "/v1/events", map[string]any{"type": typ, "data": data}, nil) } diff --git a/internal/box/notify.go b/internal/box/notify.go index de3ec0a71..3b6d4aa2a 100644 --- a/internal/box/notify.go +++ b/internal/box/notify.go @@ -31,7 +31,9 @@ import ( // The watches, the reports not yet delivered and what was already said // live in one private file, so a restart loses none of them. Reports are // read off the turn ledger and the runs engine, never off a live event, so -// one missed while berthd was down is still made. +// one missed while berthd was down is still made. The one exception is +// the answer to a secret request (secretrequests.go), which is said with +// Tell when the request ends, and kept in the same file until it is. // CallerHeader names the agent session that asks for work, from its // BERTH_SESSION: the box reports back to it. Only callers on the box's own @@ -343,7 +345,7 @@ func (n *Notifier) Forget(parent, id string) int { } left := reps[:0] for _, r := range reps { - if r.Turn == id || r.Run == id || (r.Kind == "task" && r.Session == id && r.Turn == "") { + if r.Turn == id || r.Run == id || (r.Kind == "task" && r.Session == id && r.Turn == "") || r.Key == "secret:"+id { n.markDone(r.Key) continue } diff --git a/internal/box/repoconfig.go b/internal/box/repoconfig.go index da2c1d019..6b3b55ec0 100644 --- a/internal/box/repoconfig.go +++ b/internal/box/repoconfig.go @@ -15,6 +15,7 @@ import ( "github.com/cosscom/shipyard/internal/hooks" "github.com/cosscom/shipyard/internal/statefile" + "github.com/cosscom/shipyard/internal/wire" ) // What a repository asks of every worktree: its own ports, environment, @@ -89,6 +90,9 @@ func (c RepoConfig) validate() error { var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +// ValidEnvName says whether k can be an environment variable's name. +func ValidEnvName(k string) bool { return envName.MatchString(k) } + // Merge lays local over repo, as a box does with its own config. func Merge(repo, local RepoConfig) RepoConfig { return merge(repo, local) } @@ -511,6 +515,21 @@ func (b *Box) putConfig(w http.ResponseWriter, r *http.Request) error { if err := b.before(r, "config.change", map[string]any{"location": name}); err != nil { return err } + // Where the project's secrets go is set in Settings → Secrets (PUT + // /v1/secrets/settings), from a paired laptop: a config written + // without it keeps it, and the box's own socket can't change it. + if saved, err := b.Locations.saved(name); err == nil { + var had *SecretStoreChoice + if saved.Config != nil { + had = saved.Config.Secrets + } + switch { + case req.Local.Secrets == nil: + req.Local.Secrets = had + case wire.IsLocal(r.Context()) && (had == nil || *had != *req.Local.Secrets): + return httpError{http.StatusForbidden, "where a project's secrets are saved is set from the Shipyard app, in Settings → Secrets"} + } + } if err := b.Locations.SetLocalConfig(name, req.Local); err != nil { if err == ErrUnknownLocation { return err diff --git a/internal/box/secretrequests.go b/internal/box/secretrequests.go index fad644a85..66a39d638 100644 --- a/internal/box/secretrequests.go +++ b/internal/box/secretrequests.go @@ -90,6 +90,9 @@ type SecretRequests struct { mu sync.Mutex loaded bool all []*SecretRequest + // saving are the requests a save has claimed: no other save, decline + // or timeout settles them while their value is being written. + saving map[string]bool } const keepEndedRequests = 24 * time.Hour @@ -182,26 +185,61 @@ func (s *SecretRequests) Pending() []SecretRequest { return out } -// ForSession is the request a session waits on, if any. -func (s *SecretRequests) ForSession(name string) *SecretRequest { +// BySession is the request each session waits on. +func (s *SecretRequests) BySession() map[string]*SecretRequest { + out := map[string]*SecretRequest{} for _, r := range s.Pending() { - if r.Session == name { - return &r + if _, ok := out[r.Session]; !ok { + out[r.Session] = &r } } - return nil + return out +} + +// claim takes a pending request for one save, until it ends or is +// released. +func (s *SecretRequests) claim(id string) (SecretRequest, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.load() + for _, r := range s.all { + if r.ID != id { + continue + } + switch { + case r.Status != secretPending: + return *r, httpError{http.StatusConflict, "this request is " + strings.ReplaceAll(r.Status, "_", " ") + " already"} + case s.saving[id]: + return *r, httpError{http.StatusConflict, "this request is being saved already"} + } + if s.saving == nil { + s.saving = map[string]bool{} + } + s.saving[id] = true + return *r, nil + } + return SecretRequest{}, httpError{http.StatusNotFound, "no such secret request"} +} + +// release gives back a claim whose save failed: the request waits again. +func (s *SecretRequests) release(id string) { + s.mu.Lock() + delete(s.saving, id) + s.mu.Unlock() } -// end settles a pending request; false when it was settled already. -func (s *SecretRequests) end(id, status, ref string) (SecretRequest, bool) { +// end settles a pending request; false when it was settled already, or +// is claimed by a save and this isn't that save (claimed). +func (s *SecretRequests) end(id, status, ref string, claimed bool) (SecretRequest, bool) { s.mu.Lock() defer s.mu.Unlock() s.load() for _, r := range s.all { if r.ID == id { - if r.Status != secretPending { + if r.Status != secretPending || s.saving[id] != claimed { return *r, false } + delete(s.saving, id) now := s.now() r.Status, r.Ref, r.Ended = status, ref, &now s.save() @@ -211,7 +249,8 @@ func (s *SecretRequests) end(id, status, ref string) (SecretRequest, bool) { return SecretRequest{}, false } -// expired are the pending requests whose hour is up. +// expired are the pending requests whose hour is up, but for one being +// saved. func (s *SecretRequests) expired() []string { now := s.now() var ids []string @@ -405,13 +444,18 @@ func (b *Box) saveSecretRequest(w http.ResponseWriter, r *http.Request) error { case strings.ContainsRune(value, 0): return badRequest("the value holds a NUL character; copy it again") } - sr, ok := b.SecretRequests.Get(r.PathValue("id")) - if !ok { - return httpError{http.StatusNotFound, "no such secret request"} - } - if sr.Status != secretPending { - return httpError{http.StatusConflict, "this request is " + strings.ReplaceAll(sr.Status, "_", " ") + " already"} + // Claimed until it ends: a decline or the timeout can't settle it while + // the value is being written, nor can a second save. + sr, err := b.SecretRequests.claim(r.PathValue("id")) + if err != nil { + return err } + saved := false + defer func() { + if !saved { + b.SecretRequests.release(sr.ID) + } + }() var expires *time.Time if req.Expires != "" && sr.Environment != "production" { d, ok := secretTTLs[req.Expires] @@ -430,22 +474,24 @@ func (b *Box) saveSecretRequest(w http.ResponseWriter, r *http.Request) error { } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() - ref, err := st.Save(ctx, SecretSave{Name: sr.Name, Value: value, Environment: sr.Environment, Location: sr.Location, Grant: sr.Grant}) + // The store and the index change together, never between the sweep's + // look at an expiry and its delete. + var ref string + err = stores.locked(func() error { + var err error + ref, err = st.Save(ctx, SecretSave{Name: sr.Name, Value: value, Environment: sr.Environment, Location: sr.Location, Grant: sr.Grant}) + if err != nil { + return httpError{http.StatusBadGateway, err.Error()} + } + return stores.Index().Put(SecretMeta{Name: sr.Name, Ref: ref, Store: sr.Store, StoreLabel: st.Label(), Where: sr.Where, Environment: sr.Environment, Created: time.Now().UTC(), Expires: expires, Session: sr.Session, Reason: sr.Reason, Location: sr.Location}) + }) value, req.Value = "", "" if err != nil { - return httpError{http.StatusBadGateway, err.Error()} - } - meta := SecretMeta{Name: sr.Name, Ref: ref, Store: sr.Store, StoreLabel: st.Label(), Where: sr.Where, Environment: sr.Environment, Created: time.Now().UTC(), Expires: expires, Session: sr.Session, Reason: sr.Reason, Location: sr.Location} - if d, ok := st.(interface{ Detail(string) (string, string) }); ok { - if backend, label := d.Detail(ref); label != "" { - meta.Backend, meta.StoreLabel = backend, label - } - } - if err := stores.Index().Put(meta); err != nil { return err } b.secrets().Forget(ref) - b.endSecretRequest(r, sr.ID, secretSaved, ref) + saved = true + b.endSecretRequest(r, sr.ID, secretSaved, ref, true) b.publish(r, "secret.saved", map[string]any{"name": sr.Name, "store": sr.Store, "where": sr.Where}) writeJSON(w, map[string]string{"ref": ref}) return nil @@ -462,19 +508,23 @@ func (b *Box) declineSecretRequest(w http.ResponseWriter, r *http.Request) error if !ok { return httpError{http.StatusNotFound, "no such secret request"} } - if sr.Status != secretPending { - return httpError{http.StatusConflict, "this request is " + strings.ReplaceAll(sr.Status, "_", " ") + " already"} + if !b.endSecretRequest(r, sr.ID, secretDeclined, "", false) { + if now, _ := b.SecretRequests.Get(sr.ID); now.Status != secretPending { + return httpError{http.StatusConflict, "this request is " + strings.ReplaceAll(now.Status, "_", " ") + " already"} + } + return httpError{http.StatusConflict, "this request is being saved"} } - b.endSecretRequest(r, sr.ID, secretDeclined, "") writeJSON(w, map[string]bool{"ok": true}) return nil } // endSecretRequest settles a request, says so, and tells its session. -func (b *Box) endSecretRequest(r *http.Request, id, status, ref string) { - sr, ok := b.SecretRequests.end(id, status, ref) +// claimed is a save settling the request it claimed. It says whether it +// settled it. +func (b *Box) endSecretRequest(r *http.Request, id, status, ref string, claimed bool) bool { + sr, ok := b.SecretRequests.end(id, status, ref, claimed) if !ok { - return + return false } data := map[string]any{"id": sr.ID, "session": sr.Session, "name": sr.Name, "status": status} if b.Events != nil { @@ -485,8 +535,9 @@ func (b *Box) endSecretRequest(r *http.Request, id, status, ref string) { b.Events.Publish(e) } if b.Reports != nil { - b.Reports.Tell(Report{Key: "secret:" + sr.ID, Parent: sr.Session, Kind: "secret", At: time.Now().UTC(), Status: status, Title: sr.Name, Ref: ref, Session: ""}) + b.Reports.Tell(Report{Key: "secret:" + sr.ID, Parent: sr.Session, Kind: "secret", At: time.Now().UTC(), Status: status, Title: sr.Name, Ref: ref}) } + return true } // SweepSecrets times out the requests nobody answered and deletes the @@ -496,12 +547,13 @@ func (b *Box) SweepSecrets(ctx context.Context) { return } for _, id := range b.SecretRequests.expired() { - b.endSecretRequest(nil, id, secretTimedOut, "") + b.endSecretRequest(nil, id, secretTimedOut, "", false) } if st := b.secrets().Stores; st != nil { - for _, name := range st.Sweep(ctx) { + for _, m := range st.Sweep(ctx) { + b.secrets().Forget(m.Ref) if b.Events != nil { - b.Events.Publish(events.Event{Type: "secret.deleted", Box: b.Name, Data: map[string]any{"name": name, "expired": true}}) + b.Events.Publish(events.Event{Type: "secret.deleted", Box: b.Name, Data: map[string]any{"name": m.Name, "store": m.Store, "where": m.Where, "expired": true}}) } } } @@ -616,12 +668,28 @@ func (b *Box) listSavedSecrets(w http.ResponseWriter, r *http.Request) error { listed := map[string]bool{} ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() - for _, pl := range storePlaces(set) { - st, err := stores.Open(pl[0], pl[1]) - var metas []SecretMeta - if err == nil { - metas, err = st.List(ctx) - } + // Each store at once: a gcloud takes a second or two to start. + places := storePlaces(set) + type listing struct { + metas []SecretMeta + err error + } + got := make([]listing, len(places)) + var wg sync.WaitGroup + for i, pl := range places { + wg.Add(1) + go func() { + defer wg.Done() + st, err := stores.Open(pl[0], pl[1]) + if err == nil { + got[i].metas, err = st.List(ctx) + } + got[i].err = err + }() + } + wg.Wait() + for i, pl := range places { + metas, err := got[i].metas, got[i].err if err != nil { out.Errors = append(out.Errors, SecretStoreErr{Store: pl[0], Where: pl[1], Error: err.Error()}) // What the box saved there still shows, from its own record. @@ -694,10 +762,13 @@ func (b *Box) deleteSavedSecret(w http.ResponseWriter, r *http.Request) error { if meta.Environment == "production" && req.Confirm != meta.Name { return badRequest("%s is a production secret: type its name to delete it", meta.Name) } - if err := st.Delete(ctx, req.Ref); err != nil && !errors.Is(err, errSecretNotFound) { - return httpError{http.StatusBadGateway, err.Error()} - } - if err := stores.Index().Remove(req.Ref); err != nil { + err = stores.locked(func() error { + if err := st.Delete(ctx, req.Ref); err != nil && !errors.Is(err, errSecretNotFound) { + return httpError{http.StatusBadGateway, err.Error()} + } + return stores.Index().Remove(req.Ref) + }) + if err != nil { return err } b.secrets().Forget(req.Ref) diff --git a/internal/box/secretrequests_test.go b/internal/box/secretrequests_test.go index 1315651a0..49bf93652 100644 --- a/internal/box/secretrequests_test.go +++ b/internal/box/secretrequests_test.go @@ -472,3 +472,93 @@ func TestAThirdProviderPlugsInFromItsOwnFile(t *testing.T) { t.Fatalf("config %+v", cfg.Local.Secrets) } } + +func TestASaveInProgressCantBeDeclinedOrTimedOut(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + t.Setenv("XDG_RUNTIME_DIR", "") + sb := newSecretBed(t, noKeyrings) + ctx := context.Background() + sr, err := sb.local.RequestSecret(ctx, SecretRequestBody{Name: "ACME_TOKEN", Reason: "deploy"}) + if err != nil { + t.Fatal(err) + } + // A save has claimed it and is writing the value. + if _, err := sb.bx.SecretRequests.claim(sr.ID); err != nil { + t.Fatal(err) + } + if _, err := sb.bx.SecretRequests.claim(sr.ID); err == nil { + t.Fatal("a second save claimed it too") + } + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/decline", "app", nil, nil); status != 409 { + t.Fatalf("a decline during the save: %d", status) + } + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": "other"}, nil); status != 409 { + t.Fatalf("a second save during the first: %d", status) + } + *sb.clock = sb.clock.Add(2 * time.Hour) + sb.bx.SweepSecrets(ctx) + if got, _ := sb.local.SecretRequest(ctx, sr.ID); got.Status != "pending" { + t.Fatalf("the sweep settled a request being saved: %s", got.Status) + } + // The save failed and let it go: now the person can decline. + sb.bx.SecretRequests.release(sr.ID) + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/decline", "app", nil, nil); status != 200 { + t.Fatalf("a decline after: %d", status) + } + if len(sb.pendingReports("lead")) != 1 { + t.Fatalf("reports %+v", sb.pendingReports("lead")) + } +} + +func TestOnlyTheAppSetsWhereAProjectsSecretsGo(t *testing.T) { + sb := newSecretBed(t, noKeyrings) + ctx := context.Background() + if status := call(t, sb.remote, "PUT", "/v1/secrets/settings", "app", SecretSettings{Store: "local", Locations: map[string]SecretStoreChoice{"acme": {Store: "gcp", GCPProject: "acme-staging"}}}, nil); status != 200 { + t.Fatalf("settings: %d", status) + } + choice := func() *SecretStoreChoice { + cfg, _ := sb.bx.Locations.Config(ctx, "acme") + return cfg.Local.Secrets + } + // The box's own socket can't move them through the project's config. + err := sb.local.Call(ctx, http.MethodPut, "/v1/locations/acme/config", map[string]any{"local": RepoConfig{Secrets: &SecretStoreChoice{Store: "gcp", GCPProject: "evil-project"}}}, nil) + if err == nil || choice().GCPProject != "acme-staging" { + t.Fatalf("an agent moved the project's secrets: %v %+v", err, choice()) + } + // A config written without it, from either side, keeps it. + if err := sb.local.Call(ctx, http.MethodPut, "/v1/locations/acme/config", map[string]any{"local": RepoConfig{Ports: 2}}, nil); err != nil { + t.Fatal(err) + } + if status := call(t, sb.remote, "PUT", "/v1/locations/acme/config", "app", map[string]any{"local": RepoConfig{Ports: 3}}, nil); status != 200 { + t.Fatalf("config from the app: %d", status) + } + if c := choice(); c == nil || c.Store != "gcp" || c.GCPProject != "acme-staging" { + t.Fatalf("writing the config dropped it: %+v", c) + } + // Settings clears it. + call(t, sb.remote, "PUT", "/v1/secrets/settings", "app", SecretSettings{Store: "local", Locations: map[string]SecretStoreChoice{"acme": {}}}, nil) + if c := choice(); c != nil { + t.Fatalf("clearing left %+v", c) + } +} + +func TestAnExpiredSecretIsGoneFromMemoryToo(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + t.Setenv("XDG_RUNTIME_DIR", "") + ctx := context.Background() + now := time.Now() + stores := testStores(t, noKeyrings) + stores.Now = func() time.Time { return now } + st, _ := stores.Open("local", "") + ref, _ := st.Save(ctx, SecretSave{Name: "ACME_TOKEN", Value: "short-lived"}) + exp := now.Add(time.Hour) + stores.Index().Put(SecretMeta{Name: "ACME_TOKEN", Ref: ref, Store: "local", Created: now, Expires: &exp}) + s := &Secrets{Stores: stores} + if v, err := s.Resolve(ctx, ref, nil, false); err != nil || v != "short-lived" { + t.Fatalf("before: %q %v", v, err) + } + now = now.Add(61 * time.Minute) + if _, err := s.Resolve(ctx, ref, nil, false); err == nil { + t.Fatal("the cache handed out an expired secret") + } +} diff --git a/internal/box/secrets.go b/internal/box/secrets.go index 134f03d61..16da8153d 100644 --- a/internal/box/secrets.go +++ b/internal/box/secrets.go @@ -175,6 +175,13 @@ func (s *Secrets) Forget(ref string) { // (OP_SERVICE_ACCOUNT_TOKEN, OP_ACCOUNT) to berthd's own environment. Errors // say what went wrong and never contain a value. func (s *Secrets) Resolve(ctx context.Context, ref string, opEnv []string, fresh bool) (string, error) { + // A saved secret past its expiry is gone, even from memory. + if s.Stores != nil && secretProviderOf(ref) != nil { + if err := s.Stores.Expired(ref); err != nil { + s.Forget(ref) + return "", err + } + } for { s.mu.Lock() if s.cache == nil { diff --git a/internal/box/secretstore.go b/internal/box/secretstore.go index e9db585ca..9864dd28d 100644 --- a/internal/box/secretstore.go +++ b/internal/box/secretstore.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "os" - "os/exec" "path/filepath" "regexp" "sort" @@ -73,8 +72,6 @@ type StoreEnv struct { Box string // Where is the place in the provider: a GCP project. Where string - // Index is the box's own record of what it saved. - Index *SecretIndex // Bins overrides the programs a provider runs, by name ("security", // "secret-tool", "gcloud"): tests' fakes. Bins map[string]string @@ -87,7 +84,22 @@ func (e StoreEnv) bin(name string) (string, error) { } return p, nil } - return exec.LookPath(name) + if p, err := toolPath(name); err == nil { + return p, nil + } + // Where gcloud and the like install themselves, which a service + // manager's PATH may leave out. + dirs := []string{"/opt/homebrew/bin", "/usr/local/bin", "/snap/bin"} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, "google-cloud-sdk", "bin")) + } + for _, dir := range dirs { + p := filepath.Join(dir, name) + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, nil + } + } + return "", fmt.Errorf("%s is not installed on this box", name) } // secretProvider is one registered provider. @@ -105,9 +117,7 @@ type secretProvider struct { // WhereOf is the place a reference names, so it resolves without the // settings that saved it. WhereOf func(ref string) string - // RefName is the secret's name in a reference. - RefName func(ref string) string - Open func(env StoreEnv) SecretStore + Open func(env StoreEnv) SecretStore } var ( @@ -178,7 +188,6 @@ type SecretMeta struct { // Where the place in the store (a GCP project). StoreLabel string `json:"store_label"` Where string `json:"where,omitempty"` - Backend string `json:"backend,omitempty"` Environment string `json:"environment,omitempty"` Created time.Time `json:"created"` Expires *time.Time `json:"expires,omitempty"` @@ -283,6 +292,24 @@ type SecretStores struct { once sync.Once index *SecretIndex + // mu keeps a store's write and its index entry together, apart from the + // sweep's check and delete. + mu sync.Mutex +} + +// locked runs f with the stores' writes held. +func (s *SecretStores) locked(f func() error) error { + s.mu.Lock() + defer s.mu.Unlock() + return f() +} + +// Expired says whether ref is a saved secret past its expiry. +func (s *SecretStores) Expired(ref string) error { + if m, ok := s.Index().Get(ref); ok && m.expired(s.now()) { + return fmt.Errorf("%s %w on %s", m.Name, errSecretExpired, m.Expires.Local().Format("2 Jan 15:04")) + } + return nil } func (s *SecretStores) now() time.Time { @@ -299,7 +326,7 @@ func (s *SecretStores) Index() *SecretIndex { } func (s *SecretStores) env(where string) StoreEnv { - return StoreEnv{Dir: s.Dir, Box: s.Box, Where: where, Index: s.Index(), Bins: s.Bins} + return StoreEnv{Dir: s.Dir, Box: s.Box, Where: where, Bins: s.Bins} } // Open is provider id's store at where. @@ -340,30 +367,37 @@ func (s *SecretStores) Resolve(ctx context.Context, ref string) (string, error) if err != nil { return "", err } - if m, ok := s.Index().Get(ref); ok && m.expired(s.now()) { - return "", fmt.Errorf("%s %w on %s", m.Name, errSecretExpired, m.Expires.Local().Format("2 Jan 15:04")) + if err := s.Expired(ref); err != nil { + return "", err } return st.Resolve(ctx, ref) } // Sweep deletes the saved secrets whose time is up, from their stores and -// the index. It returns the names it deleted. -func (s *SecretStores) Sweep(ctx context.Context) []string { - now := s.now() - var gone []string +// the index, and returns them. Each is looked at again with writes held, +// so a replacement saved since is never deleted. +func (s *SecretStores) Sweep(ctx context.Context) []SecretMeta { + var gone []SecretMeta for _, m := range s.Index().All() { - if !m.expired(now) { - continue - } - st, err := s.ForRef(m.Ref) - if err == nil { - err = st.Delete(ctx, m.Ref) - } - if err != nil && !errors.Is(err, errSecretNotFound) { + if !m.expired(s.now()) { continue } - s.Index().Remove(m.Ref) - gone = append(gone, m.Name) + s.locked(func() error { + now, ok := s.Index().Get(m.Ref) + if !ok || !now.expired(s.now()) { + return nil + } + st, err := s.ForRef(m.Ref) + if err == nil { + err = st.Delete(ctx, m.Ref) + } + if err != nil && !errors.Is(err, errSecretNotFound) { + return err + } + s.Index().Remove(m.Ref) + gone = append(gone, now) + return nil + }) } return gone } diff --git a/internal/box/secretstore_gcp.go b/internal/box/secretstore_gcp.go index 63ce80612..2877cdd4a 100644 --- a/internal/box/secretstore_gcp.go +++ b/internal/box/secretstore_gcp.go @@ -34,12 +34,6 @@ func init() { } return "" }, - RefName: func(ref string) string { - if m := gcpRef.FindStringSubmatch(ref); m != nil { - return m[2] - } - return "" - }, Open: func(env StoreEnv) SecretStore { return &gcpStore{env: env} }, }) } diff --git a/internal/box/secretstore_local.go b/internal/box/secretstore_local.go index 18ac284b9..95b6e7008 100644 --- a/internal/box/secretstore_local.go +++ b/internal/box/secretstore_local.go @@ -44,7 +44,6 @@ func init() { ID: "local", Scheme: "box", Name: "Local", Caps: StoreCaps{Expiry: true}, ValidateRef: validateBoxRef, - RefName: func(ref string) string { return strings.TrimPrefix(ref, "box://") }, Open: func(env StoreEnv) SecretStore { return &localStore{env: env} }, }) } @@ -72,6 +71,8 @@ const localService = "dev.berth.secrets" type localStore struct { env StoreEnv + // used is the backend the last save went to. + used localBackend } func (l *localStore) dir() string { return filepath.Join(l.env.Dir, "secrets") } @@ -145,17 +146,13 @@ func (l *localStore) backendFor(kind string) (localBackend, error) { return nil, fmt.Errorf("unknown local backend %q", kind) } +// Label is where a value goes: the backend the last save used, else the +// one a save would use now. func (l *localStore) Label() string { - return backendLabels[l.backend(context.Background()).kind()] -} - -// Detail is the label of the backend that keeps ref. -func (l *localStore) Detail(ref string) (backend, label string) { - r, ok := l.records()[strings.TrimPrefix(ref, "box://")] - if !ok { - return "", "" + if l.used == nil { + l.used = l.backend(context.Background()) } - return r.Backend, backendLabels[r.Backend] + return backendLabels[l.used.kind()] } func (l *localStore) Save(ctx context.Context, s SecretSave) (string, error) { @@ -163,6 +160,7 @@ func (l *localStore) Save(ctx context.Context, s SecretSave) (string, error) { return "", fmt.Errorf("%q is not a secret name", s.Name) } be := l.backend(ctx) + l.used = be // A value that moves backend leaves nothing behind in the old one. old, had := l.records()[s.Name] if err := be.put(ctx, s.Name, s.Value); err != nil { @@ -201,7 +199,7 @@ func (l *localStore) Resolve(ctx context.Context, ref string) (string, error) { func (l *localStore) List(ctx context.Context) ([]SecretMeta, error) { var out []SecretMeta for name, r := range l.records() { - out = append(out, SecretMeta{Name: name, Ref: "box://" + name, Store: "local", StoreLabel: backendLabels[r.Backend], Backend: r.Backend, Created: r.Created}) + out = append(out, SecretMeta{Name: name, Ref: "box://" + name, Store: "local", StoreLabel: backendLabels[r.Backend], Created: r.Created}) } return out, nil } @@ -320,7 +318,8 @@ func (s *secretServiceBackend) get(ctx context.Context, name string) (string, er } return "", fmt.Errorf("the Secret Service did not give %s: %s", name, stderr) } - return out, nil + // secret-tool ends what it prints with a newline of its own. + return strings.TrimSuffix(out, "\n"), nil } func (s *secretServiceBackend) del(ctx context.Context, name string) error { diff --git a/internal/box/secretstore_test.go b/internal/box/secretstore_test.go index 2cd3bca98..40875b75c 100644 --- a/internal/box/secretstore_test.go +++ b/internal/box/secretstore_test.go @@ -117,7 +117,8 @@ func fakeSecretTool(dir string, args []string) int { if err != nil || name == "" { return 1 } - os.Stdout.Write(v) + // As the real one does, it ends with a newline of its own. + os.Stdout.Write(append(v, '\n')) return 0 case "store": v, _ := io.ReadAll(os.Stdin) @@ -333,8 +334,8 @@ func TestTheKeychainGetsTheValueOnStdinNeverAsAnArgument(t *testing.T) { if !strings.Contains(log, "-i\n") { t.Fatalf("security was not run interactively:\n%s", log) } - if b, l := st.(*localStore).Detail(ref); b != backendKeychain || l != "Keychain" { - t.Fatalf("detail %q %q", b, l) + if r := st.(*localStore).records()["ACME_TOKEN"]; r.Backend != backendKeychain || st.Label() != "Keychain" { + t.Fatalf("kept in %q, labelled %q", r.Backend, st.Label()) } if err := st.Delete(ctx, ref); err != nil { t.Fatal(err) @@ -409,7 +410,7 @@ func TestExpiredSecretsReadAsGoneAndAreSwept(t *testing.T) { if _, err := stores.Resolve(ctx, "box://SHORT"); err == nil || !errors.Is(err, errSecretExpired) { t.Fatalf("an expired secret resolved: %v", err) } - if gone := stores.Sweep(ctx); len(gone) != 1 || gone[0] != "SHORT" { + if gone := stores.Sweep(ctx); len(gone) != 1 || gone[0].Name != "SHORT" { t.Fatalf("swept %v", gone) } if _, ok := stores.Index().Get("box://SHORT"); ok { diff --git a/internal/box/tasks.go b/internal/box/tasks.go index 413ff6736..5ccda72ba 100644 --- a/internal/box/tasks.go +++ b/internal/box/tasks.go @@ -402,6 +402,10 @@ func gateOrigin(r *http.Request) string { // enrich adds what each session's agent is doing, from the turn ledger. func (b *Box) enrich(ctx context.Context, all []Session) []Session { + var asking map[string]*SecretRequest + if b.SecretRequests != nil { + asking = b.SecretRequests.BySession() + } for i := range all { s := &all[i] if u, ok := b.Sessions.scopeUsage(ctx, *s); ok && !s.Exited { @@ -467,13 +471,11 @@ func (b *Box) enrich(ctx context.Context, all []Session) []Session { } // A secret request waits on the person like a question does, but // the agent still takes prompts: its answer comes as one. - if b.SecretRequests != nil { - if sr := b.SecretRequests.ForSession(s.Name); sr != nil { - s.SecretRequest = sr - if s.AgentState != "waiting" || s.SoftWait { - s.AgentState, s.SoftWait, s.StateSince = "waiting", true, sr.Created - s.Ask = &Ask{Tool: "SecretRequest", Message: "Asks for the secret " + sr.Name} - } + if sr := asking[s.Name]; sr != nil { + s.SecretRequest = sr + if s.AgentState != "waiting" || s.SoftWait { + s.AgentState, s.SoftWait, s.StateSince = "waiting", true, sr.Created + s.Ask = &Ask{Tool: "SecretRequest", Message: "Asks for the secret " + sr.Name} } } } diff --git a/internal/boxcmd/secretcmd.go b/internal/boxcmd/secretcmd.go index 5f23014b1..1a4973334 100644 --- a/internal/boxcmd/secretcmd.go +++ b/internal/boxcmd/secretcmd.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "os" - "time" "github.com/cosscom/shipyard/internal/box" ) @@ -37,21 +36,8 @@ func secretRequest(ctx context.Context, c *box.Client, args []string, out io.Wri if err != nil { return err } - if *wait > 90*time.Second { - *wait = 90 * time.Second - } - if sr.Status == "pending" && *wait > 0 { - deadline := time.Now().Add(*wait) - for sr.Status == "pending" && time.Now().Before(deadline) { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(time.Second): - } - if now, err := c.SecretRequest(ctx, sr.ID); err == nil { - sr = now - } - } + if sr, err = c.AwaitSecretRequest(ctx, sr, *wait); err != nil { + return err } if err := show(out, *asJSON, sr, func() { switch sr.Status { diff --git a/internal/mcpserver/mcp.go b/internal/mcpserver/mcp.go index a9fe5209f..60050e713 100644 --- a/internal/mcpserver/mcp.go +++ b/internal/mcpserver/mcp.go @@ -457,17 +457,10 @@ func secretRequest(ctx context.Context, s *Server, a map[string]any) (any, error if err != nil { return nil, err } - deadline := time.Now().Add(seconds(a, "wait", 0, 90)) - for sr.Status == "pending" && time.Now().Before(deadline) { - select { - case <-ctx.Done(): - deadline = time.Now() - continue - case <-time.After(time.Second): - } - if now, err := s.Box.SecretRequest(ctx, sr.ID); err == nil { - sr = now - } + // The tool's own deadline ending the wait is not an error: the + // request stands, and its answer comes as a notification. + if now, err := c.AwaitSecretRequest(ctx, sr, seconds(a, "wait", 0, 90)); err == nil || ctx.Err() != nil { + sr = now } out := map[string]any{"name": sr.Name, "status": sr.Status} switch sr.Status { From 16e9a539f5205a89df3b35d7141c5b7f2c712317 Mon Sep 17 00:00:00 2001 From: Stener1 Date: Wed, 4 Nov 2026 13:21:00 +0000 Subject: [PATCH 3/3] feat: name secrets by your own template in GCP, and send one to a private project from the card (BRA-2860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QRBpGMdZnu8NsP1S8UGDRj --- .../verify-berthd/features/secret-request.md | 7 +- .../features/settings-secrets.md | 2 +- .../scripts/scenarios/secret-request.mjs | 22 +- .../scripts/scenarios/settings-secrets.mjs | 5 +- .../scripts/secret-request-live.sh | 4 +- .../conversation/secret-request-card.tsx | 25 ++- app/src/lib/api.ts | 23 +- app/src/lib/mock-secrets.ts | 30 ++- app/src/lib/secret-request.test.ts | 19 ++ app/src/lib/secret-request.ts | 11 + app/src/views/settings/secrets-section.tsx | 42 +++- docs/changelog.mdx | 4 +- docs/guides/secrets.mdx | 30 ++- docs/reference/app-api.mdx | 4 +- internal/box/secretrequests.go | 98 ++++++-- internal/box/secretrequests_test.go | 210 ++++++++++++++++++ internal/box/secretstore.go | 80 ++++++- internal/box/secretstore_gcp.go | 22 +- 18 files changed, 578 insertions(+), 60 deletions(-) diff --git a/.claude/skills/verify-berthd/features/secret-request.md b/.claude/skills/verify-berthd/features/secret-request.md index 4a9773ee3..d9a26b315 100644 --- a/.claude/skills/verify-berthd/features/secret-request.md +++ b/.claude/skills/verify-berthd/features/secret-request.md @@ -8,7 +8,9 @@ An agent that needs an API key or a token asks the person for it with `berthd se The card shows the name (Replace NAME, with when the old one was saved, when `replace`), the reason verbatim, who asks, where it goes (`store_label · where`), the environment, any grant ("Grants … read access to this one secret"), a masked field with a reveal toggle (Show the value / Hide the value), an expiry picker (No expiry, 1 hour, 24 hours, 7 days; the agent's `ttl` preselected), and "The agent can use this in commands. A command that prints it puts it in the chat." **Production** shows the environment, project and grant in the warning colour (`text-warning-foreground`) and has no expiry picker: it never expires. -**Save securely** reads the field once and sends it in one request, `POST secrets/requests/{id}/save` with `{ value, expires }`; the box answers with a reference (`box://NAME`, `gcp://projects/P/secrets/NAME`). The field is uncontrolled, so the value is in no React state, prop or attribute while typed, and after the save it is cleared and the card goes. A save the box refuses (or a box away) shows its words on the card, and the value stays in the field only: nothing queues or retries it. **Decline** posts `secrets/requests/{id}/decline`. Both buttons are disabled while either is in flight. +**Where it goes.** When the store is GCP and the box names more than one project, the card has a **Project** select with the request's own first and the box's private project marked "(private)" (`where_options`, `private_where`). What the person picks goes with the save as `where`; the agent can't choose it. When a name template makes the secret's name in the store differ from the name the agent uses, **Saved as** shows it (`secret_id`). + +**Save securely** reads the field once and sends it in one request, `POST secrets/requests/{id}/save` with `{ value, expires, where? }`; the box answers with a reference (`box://NAME`, `gcp://projects/P/secrets/NAME`). The field is uncontrolled, so the value is in no React state, prop or attribute while typed, and after the save it is cleared and the card goes. A save the box refuses (or a box away) shows its words on the card, and the value stays in the field only: nothing queues or retries it. **Decline** posts `secrets/requests/{id}/decline`. Both buttons are disabled while either is in flight. ## Sub-features @@ -17,6 +19,7 @@ The card shows the name (Replace NAME, with when the old one was saved, when `re - `secret-reveal`: the toggle flips the field between `password` and `text`. - `secret-save`: one POST, then the value is in no DOM node, field, transcript item or storage, and the card goes. - `secret-production`: warning colour on environment, project and grant; no expiry picker; Replace with when. +- `secret-project-picker`: the select offers the request's project and the private one; the save goes to the one picked, with the template's name in the reference. - `secret-refused`: the box's words on the card ("Couldn't save it: … The value is still in the field, and nowhere else."), the value in the field, no second POST. - `secret-decline`: one POST to decline, the card goes, the field with it. - `secret-notification`: the needs-you notification, settled when the request ends. @@ -27,7 +30,7 @@ The card shows the name (Replace NAME, with when the old one was saved, when `re ## Driving it with drive.mjs -- On the mock: `scenarios/secret-request.mjs` opens `?mock=1&secrets=1&sidebar=list&view=conversation`. With `?secrets` (`lib/mock-secrets.ts`), devl's search-perf Claude ("Speed up product search") asks for `ACME_TOKEN` (staging, Encrypted file, `ttl: "24h"`, a two-line reason), and gpu's ci-flake Claude ("Move acme billing to the new ledger") asks to replace `ACME_LEDGER_PROD` (production, GCP Secret Manager · acme-prod, a grant, saved 9 days ago). `window.__secrets.announce()` plays their `secret.requested`. +- On the mock: `scenarios/secret-request.mjs` opens `?mock=1&secrets=1&sidebar=list&view=conversation`. With `?secrets` (`lib/mock-secrets.ts`), devl's search-perf Claude ("Speed up product search") asks for `ACME_TOKEN` (staging, Encrypted file, `ttl: "24h"`, a two-line reason), and gpu's ci-flake Claude ("Move acme billing to the new ledger") asks to replace `ACME_LEDGER_PROD` (production, GCP Secret Manager · acme-prod, a grant, saved 9 days ago), and gpu's judge-v2 Claude asks for `ACME_FRONT_KEY` (staging, saved as `ACME_FRONT_KEY_EVALS_STAGING`, acme-staging or acme-private). `window.__secrets.announce()` plays their `secret.requested`. - Second views: `window.__berthCalls` (every box call the app made, by method and path, as the mock counts them) for the one POST, and `window.__secretSaves` (box, request, expiry; never the value) for what reached the box. A value with `denied` in it is refused as GCP refuses a missing permission. - It asserts the board's two cards, the chat card's every field, that the typed value is in no attribute, the reveal toggle, the notification, the save's one POST with `7d`, that afterwards the value is in no `outerHTML`, field value, chat item or storage, the production card's warning classes and no picker, the refusal keeping the value in the field with no second POST, and Decline. Screenshots: `1-board-cards`, `2-chat-card`, `3-revealed`, `3b-notification`, `4-saved`, `5-production-card`, `6-save-refused`, `7-declined`. - `app/e2e/a11y-scenes.ts` has `chat-secret-request` (axe over the production card); `app/src/lib/secret-request.test.ts` covers the card's state, the expiry rules and production's. diff --git a/.claude/skills/verify-berthd/features/settings-secrets.md b/.claude/skills/verify-berthd/features/settings-secrets.md index d43168118..d53e6c8e2 100644 --- a/.claude/skills/verify-berthd/features/settings-secrets.md +++ b/.claude/skills/verify-berthd/features/settings-secrets.md @@ -4,7 +4,7 @@ The secrets agents asked for and the person saved, per online box, and where eac - **The list**: each secret's name, its environment (Production in the warning colour), where it is (`store_label · where`), when it expires ("never" for production), when it was saved, and who asked (the session's work, as the box still has it) and why. A store the box couldn't list (`errors`, a GCP project it has no access to) says so in red. - **Delete**: asks first. A production secret wants its name typed exactly before Delete enables, and sends it as `confirm`; a staging one needs only the confirm. The list reads again on `secret.saved` and `secret.deleted`, from anywhere. -- **Where it saves them**: the box's default store (from `stores`) and, for GCP, its project; and per project (each of the box's locations) its own store and project, or "Box default". One Save per box sends `PUT secrets/settings`. +- **Where it saves them**: the box's default store (from `stores`) and, for GCP, its project; and per project (each of the box's locations) its own store and project, or "Box default". Where GCP is on offer: the box's private project (the card also offers it), its name template (`{NAME}_{SERVICE}_{ENV}`), and each project's own template and service name while that project saves to GCP. One Save per box sends `PUT secrets/settings`. A row saved under a template says "Saved as ID". - Find a setting answers to "api token", "keys", "credentials", "secret manager"; ⌘K has it too. ## Sub-features diff --git a/.claude/skills/verify-berthd/scripts/scenarios/secret-request.mjs b/.claude/skills/verify-berthd/scripts/scenarios/secret-request.mjs index 7841e4ec1..350f771e9 100644 --- a/.claude/skills/verify-berthd/scripts/scenarios/secret-request.mjs +++ b/.claude/skills/verify-berthd/scripts/scenarios/secret-request.mjs @@ -46,7 +46,7 @@ export async function run({ page, shot, step, expect }) { const board = page.getByTestId("dashboard-board"); await expect(board).toBeVisible(); const boardCards = board.getByTestId("secret-card"); - await expect(boardCards).toHaveCount(2); + await expect(boardCards).toHaveCount(3); const boardStaging = board.locator("[data-testid=secret-card][data-secret-id=sr-acme-token]"); await expect(boardStaging.getByTestId("secret-name")).toHaveText("ACME_TOKEN"); await expect(boardStaging.getByTestId("secret-input")).toHaveAttribute("type", "password"); @@ -152,6 +152,26 @@ export async function run({ page, shot, step, expect }) { expect(await leftovers(page, REFUSED)).toEqual([]); await shot("7-declined"); + step("open gpu/judge-v2's chat: GCP under the box's name template, with the private project on offer"); + await open("gpu/judge-v2"); + const picked = chat.getByTestId("secret-card"); + await expect(picked.getByTestId("secret-name")).toHaveText("ACME_FRONT_KEY"); + await expect(picked.getByTestId("secret-id")).toHaveText("ACME_FRONT_KEY_EVALS_STAGING"); + const project = picked.getByRole("combobox", { name: "Project for ACME_FRONT_KEY" }); + await expect(project).toHaveText("acme-staging"); + await project.click(); + await page.getByRole("option", { name: "acme-private (private)" }).click(); + await expect(project).toHaveText("acme-private (private)"); + await picked.scrollIntoViewIfNeeded(); + await shot("8-project-picker"); + + step("Save securely: it goes to the project picked, under the template's name, and the agent gets that reference"); + await picked.getByTestId("secret-input").fill("acme-private-value-2860"); + await picked.getByTestId("secret-save").click(); + await expect.poll(async () => (await saves()).find((s) => s.id === "sr-acme-front")?.where).toBe("acme-private"); + await expect(chat.getByTestId("secret-card")).toHaveCount(0); + expect(await leftovers(page, "acme-private-value-2860")).toEqual([]); + step("the board again: no secret card left"); await page.keyboard.press("Meta+j"); await expect(page.getByTestId("dashboard-board")).toBeVisible(); diff --git a/.claude/skills/verify-berthd/scripts/scenarios/settings-secrets.mjs b/.claude/skills/verify-berthd/scripts/scenarios/settings-secrets.mjs index 72ce5e0de..044339481 100644 --- a/.claude/skills/verify-berthd/scripts/scenarios/settings-secrets.mjs +++ b/.claude/skills/verify-berthd/scripts/scenarios/settings-secrets.mjs @@ -44,13 +44,16 @@ export async function run({ page, shot, step, expect }) { step("where gpu saves them: its default store and GCP project, and evals' own store"); await expect(gpu.getByRole("combobox", { name: "Default store on gpu" })).toHaveText("GCP Secret Manager"); - await expect(gpu.getByRole("textbox", { name: "GCP project on gpu" })).toHaveValue("acme-staging"); + await expect(gpu.getByRole("textbox", { name: "GCP project on gpu", exact: true })).toHaveValue("acme-staging"); + await expect(gpu.getByRole("textbox", { name: "Private GCP project on gpu" })).toHaveValue("acme-private"); + await expect(gpu.getByRole("textbox", { name: "Name template on gpu" })).toHaveValue("{NAME}_{SERVICE}_{ENV}"); await expect(gpu.getByRole("combobox", { name: "Store for evals on gpu" })).toHaveText("Encrypted file"); await expect(gpu.getByRole("combobox", { name: "Store for shop on gpu" })).toHaveText("Box default"); step("give shop its own GCP project, and save"); await gpu.getByRole("combobox", { name: "Store for shop on gpu" }).click(); await page.getByRole("option", { name: "GCP Secret Manager" }).click(); await gpu.getByRole("textbox", { name: "GCP project for shop on gpu" }).fill("acme-shop"); + await gpu.getByRole("textbox", { name: "Service name for shop on gpu" }).fill("shop-api"); await gpu.getByTestId("secrets-settings-save-gpu").click(); await expect(page.getByText("Saved where gpu keeps secrets")).toBeVisible(); await expect.poll(() => page.evaluate(() => window.__berthCalls["PUT gpu/secrets/settings"] ?? 0)).toBe(1); diff --git a/.claude/skills/verify-berthd/scripts/secret-request-live.sh b/.claude/skills/verify-berthd/scripts/secret-request-live.sh index 7ea46396a..e76cc4e5e 100755 --- a/.claude/skills/verify-berthd/scripts/secret-request-live.sh +++ b/.claude/skills/verify-berthd/scripts/secret-request-live.sh @@ -92,7 +92,9 @@ echo "4. the sentinel is nowhere it must not be" # folder; the structured host's log and every state file of the box and # the laptop agent are under $dir, the encrypted store too (sealed, so it # holds no plaintext either). -proj=$HOME/.claude/projects/$(sed 's#[/.]#-#g' <<<"$dir/acme-shop") +proj=${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/$(sed 's#[/.]#-#g' <<<"$dir/acme-shop") +# A grep of the wrong folder finds nothing too: it must be the agent's. +grep -rqF "secret request $name" "$proj" 2>/dev/null || fail "the agent's transcript isn't under $proj, so a grep there proves nothing" hits=0 for where in "$dir" "$proj"; do [[ -e $where ]] || continue diff --git a/app/src/components/conversation/secret-request-card.tsx b/app/src/components/conversation/secret-request-card.tsx index e1ad72676..f05644b6e 100644 --- a/app/src/components/conversation/secret-request-card.tsx +++ b/app/src/components/conversation/secret-request-card.tsx @@ -2,6 +2,7 @@ import { CheckIcon, EyeIcon, EyeOffIcon, KeyRoundIcon, LockIcon, XIcon } from "l import { useEffect, useReducer, useRef, useState } from "react"; import { PickOne } from "@/components/pick-one"; +import { SimpleSelect } from "@/components/simple-select"; import { Tip } from "@/components/tip"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -26,6 +27,8 @@ import { isProduction, replacedLine, sentExpiry, + sentWhere, + whereChoices, } from "@/lib/secret-request"; import { useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; @@ -53,6 +56,8 @@ export function SecretRequestCard({ box, request, compact }: { box: string; requ const [typed, setTyped] = useState(0); const [revealed, setRevealed] = useState(false); const [expiry, setExpiry] = useState(() => initialExpiry(request)); + const places = whereChoices(request); + const [where, setWhere] = useState(request.where ?? ""); const prod = isProduction(request); const options = expiryOptions(request); const grant = grantLine(request); @@ -71,7 +76,7 @@ export function SecretRequestCard({ box, request, compact }: { box: string; requ if (!client || !el || !canSave(state, el.value.length)) return; dispatch({ type: "save" }); try { - const { ref } = await boxApi.saveSecret(client, box, request.id, el.value, sentExpiry(request, expiry)); + const { ref } = await boxApi.saveSecret(client, box, request.id, el.value, sentExpiry(request, expiry), sentWhere(request, where)); el.value = ""; setTyped(0); setRevealed(false); @@ -146,7 +151,7 @@ export function SecretRequestCard({ box, request, compact }: { box: string; requ
Saved in
{request.store_label || request.store} - {request.where && ( + {!places && request.where && ( <> {" · "} @@ -155,6 +160,22 @@ export function SecretRequestCard({ box, request, compact }: { box: string; requ )}
+ {places && ( + <> +
Project
+
+ +
+ + )} + {request.secret_id && ( + <> +
Saved as
+
+ {request.secret_id} +
+ + )} {request.environment && ( <>
Environment
diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index ca0dec0a3..7fae4fead 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -344,8 +344,9 @@ export const boxApi = { // saveSecret hands the person's value to the box in this one request, // which stores it and answers with its reference: the value is never // kept, queued or sent again from here (lib/secret-request.ts). - saveSecret: (c: Client, box: string, id: string, value: string, expires: SecretExpiry) => - c.box<{ ref: string }>(box, "POST", `secrets/requests/${encodeURIComponent(id)}/save`, { value, expires }), + // where is the place the person picked, when not the request's own. + saveSecret: (c: Client, box: string, id: string, value: string, expires: SecretExpiry, where?: string) => + c.box<{ ref: string }>(box, "POST", `secrets/requests/${encodeURIComponent(id)}/save`, where ? { value, expires, where } : { value, expires }), declineSecret: (c: Client, box: string, id: string) => c.box<{ ok: true }>(box, "POST", `secrets/requests/${encodeURIComponent(id)}/decline`), // The secrets saved through requests: names and where they are, never a // value. @@ -374,6 +375,13 @@ export interface SecretRequest { store: string; store_label: string; where?: string; + // The places the person may send it to instead, the request's own first, + // and the one set aside as private. + where_options?: string[]; + private_where?: string; + // What the store will call it, when a name template makes that differ + // from the name the agent uses. + secret_id?: string; environment?: "staging" | "production"; // A service account the save grants read access to this one secret. grant?: string; @@ -390,6 +398,8 @@ export interface SecretRequest { // A saved secret as Settings › Secrets lists it: never its value. export interface SecretMeta { name: string; + // What the store calls it, when a name template made that differ. + id?: string; ref: string; store: string; store_label: string; @@ -405,14 +415,17 @@ export interface SecretMeta { export interface SecretStore { id: string; label: string; - caps: { expiry: boolean; labels: boolean; grant: boolean }; + caps: { expiry: boolean; labels: boolean; grant: boolean; template?: boolean }; } -// Where a box saves secrets: its default store, and a project's own. +// Where a box saves secrets: its default store, a private project the card +// also offers, what shared stores call a secret, and a project's own. export interface SecretSettings { store: string; gcp_project?: string; - locations?: Record; + gcp_private_project?: string; + name_template?: string; + locations?: Record; } export interface SecretsList { diff --git a/app/src/lib/mock-secrets.ts b/app/src/lib/mock-secrets.ts index f85c5c997..9d2684a0f 100644 --- a/app/src/lib/mock-secrets.ts +++ b/app/src/lib/mock-secrets.ts @@ -4,7 +4,9 @@ import { ApiError, type BerthEvent, type SecretExpiry, type SecretMeta, type Sec // with ?secrets, two agents waiting on secrets they asked for (berthd // secret request): devl's search-perf Claude wants a staging token in the // box's encrypted file, gpu's ci-flake Claude a production one in GCP -// Secret Manager, with a service account granted read access. +// Secret Manager, with a service account granted read access, and its +// judge-v2 Claude a staging one under the box's name template, with a +// choice of project. // // A save is answered as a box would, with a reference; the value is never // kept. window.__secretSaves records that a save happened (box, request, @@ -23,7 +25,7 @@ const stores: Record = { devl: [LOCAL, GCP], gpu: [LOCAL, const settings: Record = { devl: { store: "local" }, - gpu: { store: "gcp", gcp_project: "acme-staging", locations: { evals: { store: "local" } } }, + gpu: { store: "gcp", gcp_project: "acme-staging", gcp_private_project: "acme-private", name_template: "{NAME}_{SERVICE}_{ENV}", locations: { evals: { store: "local" } } }, }; const saved: Record = { @@ -65,6 +67,19 @@ export function seedSecretRequests(sessions: Record) { environment: "staging", ttl: "24h", }); + put("gpu", "judge-v2-claude", { + id: "sr-acme-front", + name: "ACME_FRONT_KEY", + reason: "Compare the judge's picks with the Front stock levels", + location: "evals", + store: "gcp", + store_label: "GCP Secret Manager", + where: "acme-staging", + where_options: ["acme-staging", "acme-private"], + private_where: "acme-private", + secret_id: "ACME_FRONT_KEY_EVALS_STAGING", + environment: "staging", + }); put("gpu", "ci-flake-claude", { id: "sr-acme-ledger", name: "ACME_LEDGER_PROD", @@ -119,16 +134,17 @@ export function secretsCall(box: string, method: string, path: string, body: unk endRequest(ctx, box, s, r, "declined"); return delay({ ok: true }); } - const req = (body ?? {}) as { value?: string; expires?: SecretExpiry }; + const req = (body ?? {}) as { value?: string; expires?: SecretExpiry; where?: string }; if (!req.value) return Promise.reject(new ApiError("a secret needs a value", 400)); if (req.value.includes("denied")) return new Promise((_, reject) => setTimeout(() => reject(new ApiError(`missing permission secretmanager.secrets.create on project ${r.where ?? "acme-staging"}`, 403)), 300)); const expires = r.environment === "production" ? "" : (req.expires ?? ""); - const w = window as unknown as { __secretSaves?: { box: string; id: string; expires: string }[] }; - (w.__secretSaves ??= []).push({ box, id: r.id, expires }); - const ref = r.store === "gcp" ? `gcp://projects/${r.where}/secrets/${r.name}` : `box://${r.name}`; + const where = req.where ?? r.where; + const w = window as unknown as { __secretSaves?: { box: string; id: string; expires: string; where?: string }[] }; + (w.__secretSaves ??= []).push({ box, id: r.id, expires, where }); + const ref = r.store === "gcp" ? `gcp://projects/${where}/secrets/${r.secret_id ?? r.name}` : `box://${r.name}`; const hours = { "": 0, "1h": 1, "24h": 24, "7d": 168 }[expires]; const list = (saved[box] ??= []).filter((m) => m.name !== r.name || m.store !== r.store); - list.unshift({ name: r.name, ref, store: r.store, store_label: r.store_label, where: r.where, environment: r.environment, created: new Date().toISOString(), expires: hours ? minutes(hours * 60) : undefined, session: s.name, location: r.location, reason: r.reason }); + list.unshift({ name: r.name, id: r.secret_id, ref, store: r.store, store_label: r.store_label, where, environment: r.environment, created: new Date().toISOString(), expires: hours ? minutes(hours * 60) : undefined, session: s.name, location: r.location, reason: r.reason }); saved[box] = list; // As a box answers: after a moment, the request ends and the agent goes on. return new Promise((resolve) => diff --git a/app/src/lib/secret-request.test.ts b/app/src/lib/secret-request.test.ts index 5643b1a13..494043fb1 100644 --- a/app/src/lib/secret-request.test.ts +++ b/app/src/lib/secret-request.test.ts @@ -22,6 +22,8 @@ import { initialExpiry, pendingSecrets, replacedLine, + sentWhere, + whereChoices, secretRequestOf, sentExpiry, storeLine, @@ -187,3 +189,20 @@ test("an environment reads the same on the card and in Settings", () => { assert.equal(environmentLabel({ environment: "staging" }), "Staging"); assert.equal(environmentLabel({}), ""); }); + +test("the card offers a choice of place only when there is one, the private one marked", () => { + const r = { where: "acme-staging", where_options: ["acme-staging", "acme-private"], private_where: "acme-private" } as SecretRequest; + assert.deepEqual(whereChoices(r), [ + { value: "acme-staging", label: "acme-staging" }, + { value: "acme-private", label: "acme-private (private)" }, + ]); + assert.equal(whereChoices({ where: "acme-staging", where_options: ["acme-staging"] } as SecretRequest), undefined); + assert.equal(whereChoices({ where: "acme-staging" } as SecretRequest), undefined); +}); + +test("only a place the person changed goes to the box", () => { + const r = { where: "acme-staging" } as SecretRequest; + assert.equal(sentWhere(r, "acme-staging"), undefined); + assert.equal(sentWhere(r, "acme-private"), "acme-private"); + assert.equal(sentWhere({} as SecretRequest, ""), undefined); +}); diff --git a/app/src/lib/secret-request.ts b/app/src/lib/secret-request.ts index 12324d91e..8f541b94b 100644 --- a/app/src/lib/secret-request.ts +++ b/app/src/lib/secret-request.ts @@ -64,6 +64,17 @@ export const sentExpiry = (r: SecretRequest, chosen: SecretExpiry): SecretExpiry // storeLine says where the value goes: "GCP Secret Manager · acme-staging". export const storeLine = (r: { store_label: string; store: string; where?: string }): string => [r.store_label || r.store, r.where].filter(Boolean).join(" · "); +// whereChoices are the places the card offers, when there is a choice: the +// request's own first, the private one marked so. +export function whereChoices(r: SecretRequest): { value: string; label: string }[] | undefined { + const all = r.where_options ?? []; + if (all.length < 2) return undefined; + return all.map((w) => ({ value: w, label: w === r.private_where ? `${w} (private)` : w })); +} + +// sentWhere is what goes to the box: nothing for the request's own place. +export const sentWhere = (r: SecretRequest, chosen: string): string | undefined => (chosen && chosen !== r.where ? chosen : undefined); + export const grantLine = (r: SecretRequest): string | undefined => (r.grant ? `Grants ${r.grant} read access to this one secret` : undefined); export const cardTitle = (r: SecretRequest): string => (r.replace ? `Replace ${r.name}` : r.name); diff --git a/app/src/views/settings/secrets-section.tsx b/app/src/views/settings/secrets-section.tsx index 2a72f23ad..ef21d6140 100644 --- a/app/src/views/settings/secrets-section.tsx +++ b/app/src/views/settings/secrets-section.tsx @@ -1,5 +1,5 @@ import { KeyRoundIcon, Trash2Icon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { Fragment, useCallback, useEffect, useState } from "react"; import { SimpleSelect } from "@/components/simple-select"; import { Badge } from "@/components/ui/badge"; @@ -98,7 +98,7 @@ function SecretRow({ box, s, onDeleted }: { box: string; s: SecretMeta; onDelete const asker = s.session ? sessions?.find((x) => x.name === s.session) : undefined; const who = asker ? sessionName(asker, { sessions, agent: true }) : (s.session ?? "an agent"); const prod = isProduction(s); - const facts = [storeLine(s), `Expires: ${expiresLine(s.expires).toLowerCase()}`, s.created ? `Saved ${ago(s.created)}` : undefined].filter(Boolean).join(" · "); + const facts = [storeLine(s), s.id && s.id !== s.name ? `Saved as ${s.id}` : undefined, `Expires: ${expiresLine(s.expires).toLowerCase()}`, s.created ? `Saved ${ago(s.created)}` : undefined].filter(Boolean).join(" · "); return (
@@ -210,7 +210,7 @@ function WhereSaved({ box, stores, settings, onSaved }: { box: string; stores: S const dirty = JSON.stringify(clean(draft)) !== JSON.stringify(clean(settings)); const options = stores.map((s) => ({ value: s.id, label: s.label })); const gcp = (id?: string) => id === "gcp"; - const setLocation = (name: string, patch: { store?: string; gcp_project?: string }) => + const setLocation = (name: string, patch: { store?: string; gcp_project?: string; name_template?: string; service?: string }) => setDraft((d) => ({ ...d, locations: { ...d.locations, [name]: { ...d.locations?.[name], ...patch } } })); const save = async () => { @@ -241,13 +241,31 @@ function WhereSaved({ box, stores, settings, onSaved }: { box: string; stores: S {gcp(draft.store) && setDraft((d) => ({ ...d, gcp_project: e.target.value }))} placeholder="GCP project" aria-label={`GCP project on ${box}`} />} setDraft((d) => ({ ...d, store: v }))} aria-label={`Default store on ${box}`} /> + {stores.some((s) => gcp(s.id)) && ( + <> + + setDraft((d) => ({ ...d, gcp_private_project: e.target.value }))} placeholder="GCP project" aria-label={`Private GCP project on ${box}`} /> + + + setDraft((d) => ({ ...d, name_template: e.target.value }))} placeholder="{NAME}" aria-label={`Name template on ${box}`} /> + + + )} {(locations ?? []).map((l) => { const own = draft.locations?.[l.name]; return ( - - {gcp(own?.store) && setLocation(l.name, { gcp_project: e.target.value })} placeholder="GCP project" aria-label={`GCP project for ${l.name} on ${box}`} />} - setLocation(l.name, { store: v || undefined, gcp_project: gcp(v) ? own?.gcp_project : undefined })} aria-label={`Store for ${l.name} on ${box}`} /> - + + + {gcp(own?.store) && setLocation(l.name, { gcp_project: e.target.value })} placeholder="GCP project" aria-label={`GCP project for ${l.name} on ${box}`} />} + setLocation(l.name, { store: v || undefined, gcp_project: gcp(v) ? own?.gcp_project : undefined })} aria-label={`Store for ${l.name} on ${box}`} /> + + {gcp(own?.store || draft.store) && ( + + setLocation(l.name, { name_template: e.target.value })} placeholder={draft.name_template || "{NAME}"} aria-label={`Name template for ${l.name} on ${box}`} /> + setLocation(l.name, { service: e.target.value })} placeholder={l.name} aria-label={`Service name for ${l.name} on ${box}`} /> + + )} + ); })} @@ -268,11 +286,17 @@ function cleared(out: SecretSettings, was: SecretSettings): SecretSettings { function clean(s: SecretSettings): SecretSettings { const locations: NonNullable = {}; for (const [name, l] of Object.entries(s.locations ?? {})) { - if (!l?.store) continue; - locations[name] = l.store === "gcp" && l.gcp_project?.trim() ? { store: l.store, gcp_project: l.gcp_project.trim() } : { store: l.store }; + const own: NonNullable[string] = {}; + if (l?.store) own.store = l.store; + if (l?.store === "gcp" && l.gcp_project?.trim()) own.gcp_project = l.gcp_project.trim(); + if (l?.name_template?.trim()) own.name_template = l.name_template.trim(); + if (l?.service?.trim()) own.service = l.service.trim(); + if (Object.keys(own).length) locations[name] = own; } const out: SecretSettings = { store: s.store }; if (s.store === "gcp" && s.gcp_project?.trim()) out.gcp_project = s.gcp_project.trim(); + if (s.gcp_private_project?.trim()) out.gcp_private_project = s.gcp_private_project.trim(); + if (s.name_template?.trim()) out.name_template = s.name_template.trim(); if (Object.keys(locations).length) out.locations = locations; return out; } diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 36230a0ca..826f1b03c 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -25,7 +25,9 @@ again. `berthd secret exec --env` or `--file`. Saved secrets live in the Keychain on a Mac, a Secret Service or an encrypted file on Linux, or Google Cloud Secret Manager, and Settings → Secrets lists and deletes - them. [Secrets](/guides/secrets#when-an-agent-asks-for-a-secret) + them. In GCP, a name template gives secrets your own naming, such as + `FRONT_KEY_SHOP_API_STAGING`, and the card lets you send one to a private + project. [Secrets](/guides/secrets#when-an-agent-asks-for-a-secret) - **Agents come back after a reboot.** Starting a chat agent again after its box restarted ran its old command with its last word cut off, so Claude refused to start ("argument missing") and the tab only said it had diff --git a/docs/guides/secrets.mdx b/docs/guides/secrets.mdx index 4a1b7bd93..df9427a4b 100644 --- a/docs/guides/secrets.mdx +++ b/docs/guides/secrets.mdx @@ -124,7 +124,7 @@ hour, and the agent hears that too. |---|---| | `--reason WHY` | Shown on the card as written. Required | | `--new` | Ask for a new value even when one is saved: the card says **Replace** and when the old one was saved. Without it, a saved, unexpired secret's reference comes back at once, with no card | -| `--store local\|gcp` | Where to save it. Default: the project's choice, else the box's, set in Settings → Secrets | +| `--store local\|gcp` | Where to save it. Default: the project's choice, else the box's, set in Settings → Secrets. Agents in a project's worktrees get the project's choice too | | `--environment staging\|production` | Production shows the environment, the GCP project and any grant in a warning colour, and never expires | | `--grant SA_EMAIL` | GCP only: give that service account read access to this one secret, shown on the card first | | `--ttl 1h\|24h\|7d` | Preselect a lifetime. Saved secrets are kept until deleted unless you pick one on the card | @@ -202,6 +202,34 @@ step. The agent gets `gcp://projects/PROJECT/secrets/NAME`. Settings lists the secrets labelled `shipyard=true` in each project the box or a project names. +**A name template** says what Secret Manager calls a secret, in a project +whose names are shared with other things. Set it in Settings → Secrets, for +the box or for one project. The agent keeps asking for and using the plain +name; the card shows the full one as **Saved as**, and the reference holds it. +A word in capitals comes out in capitals with underscores, one in small +letters in small letters with hyphens: + +| Template | Project `shop-api`, `FRONT_KEY`, `--environment staging` | +|---|---| +| none | `FRONT_KEY` | +| `{NAME}_{SERVICE}_{ENV}` | `FRONT_KEY_SHOP_API_STAGING` | +| `{name}-{service}-{env}` | `front-key-shop-api-staging` | + +`{SERVICE}` is the project's name, or the service name set for it. A request +with no `--environment` under a template with `{ENV}` counts as staging, and +the card says so. Each environment is its own secret, so a saved staging value +is not handed out for production. + +**A private project** is a GCP project set once in Settings → Secrets, for what +other people shouldn't reach. When the store is GCP, the card has a **Project** +select with the request's project first and the others the box or a project +names, the private one marked; you pick at save time, and an agent can't. The +same project asking again in the same environment finds a secret you sent +elsewhere, with no second card, and a replacement goes where the old one is; +another project or environment gets a card of its own. A private project keeps out whoever has no access to +that project, not the agents on this box: they read it through the reference, +with the box's own gcloud. + ### Adding a provider Stores are providers behind one Go interface, `SecretStore` in diff --git a/docs/reference/app-api.mdx b/docs/reference/app-api.mdx index 45609c51d..bf30bc405 100644 --- a/docs/reference/app-api.mdx +++ b/docs/reference/app-api.mdx @@ -160,11 +160,11 @@ failed after it went out (the box may have acted on it). The box API: | `POST secrets/requests` | Only on the box's own socket, from an agent session (`X-Berth-Caller`): `{ name, reason, new?, store?, environment?, grant?, ttl? }` asks the person for a [secret](/guides/secrets#when-an-agent-asks-for-a-secret). Returns a pending request, or `{ status: "saved", ref, reused: true }` for one saved and unexpired | | `GET secrets/requests` | The pending requests. A session waiting on one also carries it as `secret_request`, with `agent_state` `waiting` and `soft_wait` | | `GET secrets/requests/{id}` | One request: `pending`, `saved` (with `ref`), `declined` or `timed_out` | -| `POST secrets/requests/{id}/save` | Refused on the box's own socket: `{ value, expires: "" \| "1h" \| "24h" \| "7d" }` saves the value to the request's store and answers `{ ref }`. The value is never echoed, logged or announced | +| `POST secrets/requests/{id}/save` | Refused on the box's own socket: `{ value, expires: "" \| "1h" \| "24h" \| "7d", where? }` saves the value to the request's store, or to `where`, one of its `where_options`, and answers `{ ref }`. The value is never echoed, logged or announced | | `POST secrets/requests/{id}/decline` | Refused on the box's own socket: the agent hears it was declined | | `GET secrets` | Saved secrets from every configured store, names and metadata only, with the stores, the settings and any store that couldn't be listed | | `POST secrets/delete` | Refused on the box's own socket: `{ ref, confirm? }`; a production secret needs its name as `confirm` | -| `GET secrets/settings`, `PUT secrets/settings` | The box's default store and GCP project, and each project's (kept in the box's own config for the location). `PUT` is refused on the box's own socket | +| `GET secrets/settings`, `PUT secrets/settings` | The box's default store, GCP project, private project and name template, and each project's store, project, template and service name (kept in the box's own config for the location). `PUT` is refused on the box's own socket | | `POST sessions/{name}/send` | `{ text, enter?, when?, force?, idem_key? }` → `{ sent, queued?, duplicate?, turn?, seq?, at }`: types a prompt, or with `when: "idle"` holds it until the agent is idle. With `when`, refused (409) while the agent waits for someone unless `force` ([orchestration](/guides/orchestration#send)) | | `GET sessions/{name}/wait?for=finished,waiting&after=TIME&timeout=10m` | `{ state, timed_out, turn? }`: `after` is an RFC 3339 time, placed in the box's journal order; `timeout` a duration, 1s to 1h (default 10m) | | `GET sessions/{name}/turns?limit=N` | `Turn[]`, oldest first: the session's last `N` turns (default 20). A wait's `ask` is what the agent asked for, from its hooks: `{ tool?, input?, why?, message? }`, each at most 300 characters (the command or the file's path, never what it would write) | diff --git a/internal/box/secretrequests.go b/internal/box/secretrequests.go index 66a39d638..a36b47215 100644 --- a/internal/box/secretrequests.go +++ b/internal/box/secretrequests.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "os" + "slices" "sort" "strings" "sync" @@ -61,9 +62,16 @@ type SecretRequest struct { // project). Store string `json:"store"` StoreLabel string `json:"store_label"` - Where string `json:"where,omitempty"` - Environment string `json:"environment,omitempty"` - Grant string `json:"grant,omitempty"` + Where string `json:"where,omitempty"` + // WhereOptions are the places the person may send it to instead, the + // request's own first; PrivateWhere is the one among them set aside as + // private. SecretID is what the store will call it, when a name + // template makes that differ from Name. + WhereOptions []string `json:"where_options,omitempty"` + PrivateWhere string `json:"private_where,omitempty"` + SecretID string `json:"secret_id,omitempty"` + Environment string `json:"environment,omitempty"` + Grant string `json:"grant,omitempty"` // TTL is the lifetime the agent suggests; the card preselects it. TTL string `json:"ttl,omitempty"` // Replace is a request for a new value over one saved before, at @@ -355,11 +363,29 @@ func (b *Box) createSecretRequest(w http.ResponseWriter, r *http.Request) error return badRequest("a production secret doesn't expire: leave out the ttl") } } - id, where := Choose(req.Store, b.projectChoice(sess.Location), stores.BoxSettings()) + // A session in a worktree is at project/worktree. + project, _, _ := strings.Cut(sess.Location, "/") + own, boxChoice := b.projectChoice(project), stores.BoxSettings() + id, where := Choose(req.Store, own, boxChoice) st, err := stores.Open(id, where) if err != nil { return badRequest("%v", err) } + storeID := req.Name + if secretProviderByID(id).Caps.Template { + template := firstNonEmptyStr(own.NameTemplate, boxChoice.NameTemplate) + service := firstNonEmptyStr(own.Service, project) + if templateNeeds(template, "service") && service == "" { + return badRequest("the name template needs a service, and this session is in no project") + } + if templateNeeds(template, "env") && req.Environment == "" { + req.Environment = "staging" + } + storeID = secretID(template, req.Name, service, req.Environment) + if !secretIDPattern.MatchString(storeID) { + return badRequest("%q can't be a secret's name in the %s store", storeID, id) + } + } if req.Grant != "" { if !secretProviderByID(id).Caps.Grant { return badRequest("the %s store can't grant access: --grant is for gcp", id) @@ -371,20 +397,36 @@ func (b *Box) createSecretRequest(w http.ResponseWriter, r *http.Request) error // One saved and unexpired is given at once, with no card. var prev *SecretMeta now := time.Now() + private := boxChoice.privateFor(id) + offered := b.whereOptions(id, where) for _, m := range stores.Index().All() { - if m.Name == req.Name && m.Store == id && m.Where == where && !m.expired(now) { + if m.Name != req.Name || m.Store != id || firstNonEmptyStr(m.ID, m.Name) != storeID || m.expired(now) { + continue + } + // The project this request saves to is the same secret for anyone. + // One the person sent elsewhere on a card is only this project's, + // in this environment: it's no one else's to be handed. + if m.Where == where || (prev == nil && slices.Contains(offered, m.Where) && m.Location == project && m.Environment == req.Environment) { m := m prev = &m } } if prev != nil && !req.New { - writeJSON(w, SecretRequest{Name: req.Name, Session: caller, Store: id, StoreLabel: prev.StoreLabel, Where: where, Status: secretSaved, Ref: prev.Ref, Reused: true}) + writeJSON(w, SecretRequest{Name: req.Name, Session: caller, Store: id, StoreLabel: prev.StoreLabel, Where: prev.Where, Status: secretSaved, Ref: prev.Ref, Reused: true}) return nil } - sr := SecretRequest{Name: req.Name, Reason: req.Reason, Session: caller, Location: sess.Location, Store: id, StoreLabel: st.Label(), Where: where, Environment: req.Environment, Grant: req.Grant, TTL: req.TTL, Replace: req.New && prev != nil} + sr := SecretRequest{Name: req.Name, Reason: req.Reason, Session: caller, Location: project, Store: id, StoreLabel: st.Label(), Where: where, Environment: req.Environment, Grant: req.Grant, TTL: req.TTL, Replace: req.New && prev != nil} if sr.Replace { + // A replacement goes where the old one is. created := prev.Created - sr.ReplacedAt = &created + sr.ReplacedAt, sr.Where = &created, prev.Where + } + sr.PrivateWhere = private + if opts := b.whereOptions(id, sr.Where); len(opts) > 1 { + sr.WhereOptions = opts + } + if storeID != req.Name { + sr.SecretID = storeID } sr, made := b.SecretRequests.add(sr) if made { @@ -430,6 +472,9 @@ func (b *Box) saveSecretRequest(w http.ResponseWriter, r *http.Request) error { var req struct { Value string `json:"value"` Expires string `json:"expires"` + // Where is the place the person picked on the card, one the box + // saves to; empty is the request's. + Where string `json:"where"` } if err := decodeLimit(r, &req, maxSecretValue+4<<10); err != nil { return err @@ -468,7 +513,14 @@ func (b *Box) saveSecretRequest(w http.ResponseWriter, r *http.Request) error { t := time.Now().UTC().Add(d) expires = &t } - st, err := stores.Open(sr.Store, sr.Where) + where := sr.Where + if req.Where != "" && req.Where != sr.Where { + if !slices.Contains(b.whereOptions(sr.Store, sr.Where), req.Where) { + return badRequest("%s isn't a place this box saves to: add it in Settings → Secrets", req.Where) + } + where = req.Where + } + st, err := stores.Open(sr.Store, where) if err != nil { return badRequest("%v", err) } @@ -479,11 +531,11 @@ func (b *Box) saveSecretRequest(w http.ResponseWriter, r *http.Request) error { var ref string err = stores.locked(func() error { var err error - ref, err = st.Save(ctx, SecretSave{Name: sr.Name, Value: value, Environment: sr.Environment, Location: sr.Location, Grant: sr.Grant}) + ref, err = st.Save(ctx, SecretSave{Name: sr.Name, ID: sr.SecretID, Value: value, Environment: sr.Environment, Location: sr.Location, Grant: sr.Grant}) if err != nil { return httpError{http.StatusBadGateway, err.Error()} } - return stores.Index().Put(SecretMeta{Name: sr.Name, Ref: ref, Store: sr.Store, StoreLabel: st.Label(), Where: sr.Where, Environment: sr.Environment, Created: time.Now().UTC(), Expires: expires, Session: sr.Session, Reason: sr.Reason, Location: sr.Location}) + return stores.Index().Put(SecretMeta{Name: sr.Name, ID: sr.SecretID, Ref: ref, Store: sr.Store, StoreLabel: st.Label(), Where: where, Environment: sr.Environment, Created: time.Now().UTC(), Expires: expires, Session: sr.Session, Reason: sr.Reason, Location: sr.Location}) }) value, req.Value = "", "" if err != nil { @@ -492,7 +544,7 @@ func (b *Box) saveSecretRequest(w http.ResponseWriter, r *http.Request) error { b.secrets().Forget(ref) saved = true b.endSecretRequest(r, sr.ID, secretSaved, ref, true) - b.publish(r, "secret.saved", map[string]any{"name": sr.Name, "store": sr.Store, "where": sr.Where}) + b.publish(r, "secret.saved", map[string]any{"name": sr.Name, "store": sr.Store, "where": where}) writeJSON(w, map[string]string{"ref": ref}) return nil } @@ -598,7 +650,7 @@ type SecretStoreErr struct { func (b *Box) secretSettings() SecretSettings { stores := b.secrets().Stores def := stores.BoxSettings() - out := SecretSettings{Store: def.Store, GCPProject: def.GCPProject, Locations: map[string]SecretStoreChoice{}} + out := SecretSettings{Store: def.Store, GCPProject: def.GCPProject, GCPPrivateProject: def.GCPPrivateProject, NameTemplate: def.NameTemplate, Locations: map[string]SecretStoreChoice{}} if b.Locations != nil { if all, err := b.Locations.read(); err == nil { for _, l := range all { @@ -634,6 +686,7 @@ func storePlaces(set SecretSettings) [][2]string { boxDefault := SecretStoreChoice{Store: set.Store, GCPProject: set.GCPProject} for _, id := range secretProviderIDs() { add(id, boxDefault) + add(id, SecretStoreChoice{GCPProject: set.GCPPrivateProject}) names := make([]string, 0, len(set.Locations)) for n := range set.Locations { names = append(names, n) @@ -650,6 +703,21 @@ func storePlaces(set SecretSettings) [][2]string { return out } +// whereOptions are the places a save to store id may go: first, then the +// others the box or a project names. +func (b *Box) whereOptions(id, first string) []string { + out := []string{} + if first != "" { + out = append(out, first) + } + for _, pl := range storePlaces(b.secretSettings()) { + if pl[0] == id && pl[1] != "" && !slices.Contains(out, pl[1]) { + out = append(out, pl[1]) + } + } + return out +} + func (b *Box) listSavedSecrets(w http.ResponseWriter, r *http.Request) error { stores, err := b.secretStores() if err != nil { @@ -797,7 +865,7 @@ func (b *Box) putSecretSettings(w http.ResponseWriter, r *http.Request) error { if err := decode(r, &req); err != nil { return err } - boxChoice := SecretStoreChoice{Store: firstNonEmptyStr(req.Store, "local"), GCPProject: strings.TrimSpace(req.GCPProject)} + boxChoice := SecretStoreChoice{Store: firstNonEmptyStr(req.Store, "local"), GCPProject: strings.TrimSpace(req.GCPProject), GCPPrivateProject: strings.TrimSpace(req.GCPPrivateProject), NameTemplate: strings.TrimSpace(req.NameTemplate)} if err := boxChoice.validate(); err != nil { return badRequest("%v", err) } @@ -815,7 +883,7 @@ func (b *Box) putSecretSettings(w http.ResponseWriter, r *http.Request) error { return fmt.Errorf("%s: %w", name, err) } local := cfg.Local - if c.Store == "" && c.GCPProject == "" { + if c == (SecretStoreChoice{}) { local.Secrets = nil } else { cc := c diff --git a/internal/box/secretrequests_test.go b/internal/box/secretrequests_test.go index 49bf93652..9c68b3add 100644 --- a/internal/box/secretrequests_test.go +++ b/internal/box/secretrequests_test.go @@ -24,6 +24,7 @@ type secretBed struct { remote *wire.Client local *Client state string + agent string clock *time.Time mu sync.Mutex seen []events.Event @@ -55,6 +56,7 @@ func newSecretBed(t *testing.T, bins map[string]string) *secretBed { bin := t.TempDir() fake := filepath.Join(bin, "claude") os.WriteFile(fake, []byte("#!/bin/sh\nexec cat\n"), 0o755) + sb.agent = fake for _, name := range []string{"lead", "shell"} { cmd := fake if name == "shell" { @@ -562,3 +564,211 @@ func TestAnExpiredSecretIsGoneFromMemoryToo(t *testing.T) { t.Fatal("the cache handed out an expired secret") } } + +// inWorktree is a client for an agent in worktree name of project acme, as +// most agents are: its session is at acme/name. +func (sb *secretBed) inWorktree(t *testing.T, name string) *Client { + t.Helper() + if status := call(t, sb.remote, "POST", "/v1/locations/acme/worktrees", "", map[string]string{"name": name}, nil); status != 200 { + t.Fatalf("worktree: %d", status) + } + session := name + "-agent" + if status := call(t, sb.remote, "POST", "/v1/sessions", "", map[string]string{"location": "acme/" + name, "name": session, "command": sb.agent}, nil); status != 200 { + t.Fatalf("session: %d", status) + } + t.Cleanup(func() { sb.bx.Sessions.Kill(context.Background(), session) }) + return sb.local.WithCaller(session) +} + +func TestAProjectsOwnStoreCountsForAgentsInItsWorktrees(t *testing.T) { + registerAcmeVault(t) + sb := newSecretBed(t, noKeyrings) + call(t, sb.remote, "PUT", "/v1/secrets/settings", "app", SecretSettings{Store: "local", Locations: map[string]SecretStoreChoice{"acme": {Store: "acmevault"}}}, nil) + sr, err := sb.inWorktree(t, "fix").RequestSecret(context.Background(), SecretRequestBody{Name: "ACME_TOKEN", Reason: "deploy"}) + if err != nil { + t.Fatal(err) + } + if sr.Store != "acmevault" || sr.Location != "acme" { + t.Fatalf("an agent in a worktree got store %q, project %q", sr.Store, sr.Location) + } +} + +func TestANameTemplateSaysWhatTheStoreCallsASecret(t *testing.T) { + for _, c := range []struct{ template, want string }{ + {"", "FRONT_API_KEY"}, + {"{NAME}", "FRONT_API_KEY"}, + {"{NAME}_{SERVICE}_{ENV}", "FRONT_API_KEY_SHOP_BULK_EDITOR_PRODUCTION"}, + {"{name}-{service}-{env}", "front-api-key-shop-bulk-editor-production"}, + {"acme_{NAME}", "acme_FRONT_API_KEY"}, + } { + if got := secretID(c.template, "FRONT_API_KEY", "shop-bulk-editor", "production"); got != c.want { + t.Errorf("%q gave %q, want %q", c.template, got, c.want) + } + } + for _, bad := range []string{"{SERVICE}", "{NAME}_{OTHER}", "{NAME}/x", "{NAME} x"} { + if (SecretStoreChoice{NameTemplate: bad}).validate() == nil { + t.Errorf("template %q was taken", bad) + } + } + if (SecretStoreChoice{Service: "a b"}).validate() == nil { + t.Error("a service with a space was taken") + } +} + +func TestSecretsGoWhereThePersonPicksUnderTheTemplatesName(t *testing.T) { + bins, dir := fakeBins(t, "gcloud") + bins["security"], bins["secret-tool"] = noKeyrings["security"], noKeyrings["secret-tool"] + sb := newSecretBed(t, bins) + ctx := context.Background() + settings := SecretSettings{Store: "gcp", GCPProject: "acme-staging", GCPPrivateProject: "acme-private", NameTemplate: "{NAME}_{SERVICE}_{ENV}"} + if status := call(t, sb.remote, "PUT", "/v1/secrets/settings", "app", settings, nil); status != 200 { + t.Fatalf("settings: %d", status) + } + agent := sb.inWorktree(t, "fix") + + // The agent asks for the plain name; the store calls it by the template. + sr, err := agent.RequestSecret(ctx, SecretRequestBody{Name: "FRONT_KEY", Reason: "deploy", Environment: "staging"}) + if err != nil { + t.Fatal(err) + } + if sr.Name != "FRONT_KEY" || sr.SecretID != "FRONT_KEY_ACME_STAGING" || sr.Where != "acme-staging" || sr.PrivateWhere != "acme-private" || strings.Join(sr.WhereOptions, ",") != "acme-staging,acme-private" { + t.Fatalf("request %+v", sr) + } + // A place the box doesn't save to is refused, and nothing is saved. + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": "v", "where": "acme-elsewhere"}, nil); status != 400 { + t.Fatalf("an unknown project: %d", status) + } + if len(readFakeGCP(dir)) != 0 { + t.Fatalf("it saved: %+v", readFakeGCP(dir)) + } + var saved map[string]string + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": "v1"}, &saved); status != 200 || saved["ref"] != "gcp://projects/acme-staging/secrets/FRONT_KEY_ACME_STAGING" { + t.Fatalf("save: %d %v", status, saved) + } + if _, ok := readFakeGCP(dir)["FRONT_KEY_ACME_STAGING"]; !ok { + t.Fatalf("store %+v", readFakeGCP(dir)) + } + // The same name is the same secret; another environment is another one. + again, err := agent.RequestSecret(ctx, SecretRequestBody{Name: "FRONT_KEY", Reason: "again", Environment: "staging"}) + if err != nil || !again.Reused || again.Ref != saved["ref"] { + t.Fatalf("reuse: %+v %v", again, err) + } + prod, err := agent.RequestSecret(ctx, SecretRequestBody{Name: "FRONT_KEY", Reason: "release", Environment: "production"}) + if err != nil || prod.Reused || prod.SecretID != "FRONT_KEY_ACME_PRODUCTION" { + t.Fatalf("production: %+v %v", prod, err) + } + // With no environment, one the template needs reads as staging, and + // the card says so. + plain, err := agent.RequestSecret(ctx, SecretRequestBody{Name: "PLAIN_KEY", Reason: "x"}) + if err != nil || plain.Environment != "staging" || plain.SecretID != "PLAIN_KEY_ACME_STAGING" { + t.Fatalf("no environment: %+v %v", plain, err) + } + + // Picked on the card: the private project, and the agent's reference. + private, err := agent.RequestSecret(ctx, SecretRequestBody{Name: "MY_KEY", Reason: "mine", Environment: "staging"}) + if err != nil { + t.Fatal(err) + } + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+private.ID+"/save", "app", map[string]string{"value": "v2", "where": "acme-private"}, &saved); status != 200 || saved["ref"] != "gcp://projects/acme-private/secrets/MY_KEY_ACME_STAGING" { + t.Fatalf("private save: %d %v", status, saved) + } + if log := argvLog(t, dir); !strings.Contains(log, "secrets versions add MY_KEY_ACME_STAGING --data-file=- --project=acme-private") { + t.Fatalf("gcloud ran:\n%s", log) + } + // Asked for again, it is found there, with no second card. + mine, err := agent.RequestSecret(ctx, SecretRequestBody{Name: "MY_KEY", Reason: "mine again", Environment: "staging"}) + if err != nil || !mine.Reused || mine.Ref != saved["ref"] || mine.Where != "acme-private" { + t.Fatalf("private reuse: %+v %v", mine, err) + } + // A replacement goes where the old one is. + repl, err := agent.RequestSecret(ctx, SecretRequestBody{Name: "MY_KEY", Reason: "rotate", Environment: "staging", New: true}) + if err != nil || repl.Where != "acme-private" || repl.WhereOptions[0] != "acme-private" { + t.Fatalf("replacement: %+v %v", repl, err) + } + // Settings lists the private project's secrets too, by the asked name. + var list SavedSecrets + call(t, sb.remote, "GET", "/v1/secrets", "", nil, &list) + found := false + for _, m := range list.Secrets { + found = found || (m.Name == "MY_KEY" && m.ID == "MY_KEY_ACME_STAGING" && m.Where == "acme-private") + } + if !found || list.Settings.GCPPrivateProject != "acme-private" || list.Settings.NameTemplate != "{NAME}_{SERVICE}_{ENV}" { + t.Fatalf("list %+v", list) + } + // A project's service name is its own. + call(t, sb.remote, "PUT", "/v1/secrets/settings", "app", SecretSettings{Store: "gcp", GCPProject: "acme-staging", NameTemplate: "{NAME}_{SERVICE}_{ENV}", Locations: map[string]SecretStoreChoice{"acme": {Service: "shop-api"}}}, nil) + named, err := agent.RequestSecret(ctx, SecretRequestBody{Name: "OTHER_KEY", Reason: "x", Environment: "production"}) + if err != nil || named.SecretID != "OTHER_KEY_SHOP_API_PRODUCTION" { + t.Fatalf("service: %+v %v", named, err) + } + // The box's socket can't move the template either. + if err := sb.local.Call(ctx, http.MethodPut, "/v1/locations/acme/config", map[string]any{"local": RepoConfig{Secrets: &SecretStoreChoice{NameTemplate: "{NAME}_X"}}}, nil); err == nil { + t.Fatal("an agent changed the project's name template") + } +} + +func TestASecretSentElsewhereIsOnlyItsProjectsAndEnvironments(t *testing.T) { + bins, _ := fakeBins(t, "gcloud") + bins["security"], bins["secret-tool"] = noKeyrings["security"], noKeyrings["secret-tool"] + sb := newSecretBed(t, bins) + ctx := context.Background() + call(t, sb.remote, "POST", "/v1/locations", "", map[string]string{"name": "other", "path": gitRepo(t)}, nil) + call(t, sb.remote, "PUT", "/v1/secrets/settings", "app", SecretSettings{Store: "gcp", GCPProject: "acme-staging", GCPPrivateProject: "acme-private", Locations: map[string]SecretStoreChoice{"other": {Store: "gcp", GCPProject: "acme-other"}}}, nil) + if status := call(t, sb.remote, "POST", "/v1/sessions", "", map[string]string{"location": "other", "name": "theirs", "command": sb.agent}, nil); status != 200 { + t.Fatalf("session: %d", status) + } + t.Cleanup(func() { sb.bx.Sessions.Kill(context.Background(), "theirs") }) + theirs := sb.local.WithCaller("theirs") + ask := func(c *Client, b SecretRequestBody) SecretRequest { + t.Helper() + b.Name, b.Reason = "API_KEY", "x" + sr, err := c.RequestSecret(ctx, b) + if err != nil { + t.Fatal(err) + } + return sr + } + save := func(sr SecretRequest, where string) string { + t.Helper() + var saved map[string]string + if status := call(t, sb.remote, "POST", "/v1/secrets/requests/"+sr.ID+"/save", "app", map[string]string{"value": "v", "where": where}, &saved); status != 200 { + t.Fatalf("save to %s: %d", where, status) + } + return saved["ref"] + } + + // This project sends its staging key to the private project. + mine := save(ask(sb.local, SecretRequestBody{Environment: "staging"}), "acme-private") + if mine != "gcp://projects/acme-private/secrets/API_KEY" { + t.Fatalf("ref %s", mine) + } + // Another project asking for that name gets a card, not this one's key. + if got := ask(theirs, SecretRequestBody{Environment: "staging"}); got.Reused || got.Status != "pending" || got.Where != "acme-other" { + t.Fatalf("another project: %+v", got) + } + // So does production, here. + if got := ask(sb.local, SecretRequestBody{Environment: "production"}); got.Reused || got.Where != "acme-staging" { + t.Fatalf("another environment: %+v", got) + } + // This project in staging finds it, and a replacement goes there. + if got := ask(sb.local, SecretRequestBody{Environment: "staging"}); !got.Reused || got.Ref != mine { + t.Fatalf("reuse: %+v", got) + } + + // The same holds for any project the card offers, not just the private + // one: here, the other project's. The requests above stop waiting first. + for _, r := range sb.bx.SecretRequests.Pending() { + sb.bx.endSecretRequest(nil, r.ID, secretDeclined, "", false) + } + third := ask(sb.local, SecretRequestBody{Environment: "production"}) + if strings.Join(third.WhereOptions, ",") != "acme-staging,acme-private,acme-other" { + t.Fatalf("options %v", third.WhereOptions) + } + ref := save(third, "acme-other") + if got := ask(sb.local, SecretRequestBody{Environment: "production"}); !got.Reused || got.Ref != ref { + t.Fatalf("reuse after a third project: %+v", got) + } + if got := ask(sb.local, SecretRequestBody{Environment: "production", New: true}); !got.Replace || got.Where != "acme-other" { + t.Fatalf("rotation after a third project: %+v", got) + } +} diff --git a/internal/box/secretstore.go b/internal/box/secretstore.go index 9864dd28d..f3ed8fdb2 100644 --- a/internal/box/secretstore.go +++ b/internal/box/secretstore.go @@ -45,7 +45,10 @@ type SecretStore interface { // SecretSave is one value to keep, and what it is for. type SecretSave struct { - Name string + Name string + // ID is the secret's name in the store, when a name template makes it + // differ from Name (StoreCaps.Template); empty is Name. + ID string Value string Environment string Location string @@ -63,6 +66,9 @@ type StoreCaps struct { Labels bool `json:"labels"` // Grant: the store can give one service account access to one secret. Grant bool `json:"grant"` + // Template: the store's names are shared with other things, so a name + // template says what a secret is called there. + Template bool `json:"template"` } // StoreEnv is what a provider opens its store with. @@ -181,7 +187,10 @@ var secretName = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`) // SecretMeta is one saved secret as Settings lists it: never a value. type SecretMeta struct { + // Name is what the agent asked for and uses; ID what the store calls + // it, when a name template made that differ. Name string `json:"name"` + ID string `json:"id,omitempty"` Ref string `json:"ref"` Store string `json:"store"` // StoreLabel is where the value is ("Keychain", "GCP Secret Manager"); @@ -411,6 +420,10 @@ var errSecretNotFound = errors.New("no such secret") type SecretSettings struct { Store string `json:"store"` GCPProject string `json:"gcp_project,omitempty"` + // GCPPrivateProject is one more project the card offers, for what + // should stay out of the projects others can reach. + GCPPrivateProject string `json:"gcp_private_project,omitempty"` + NameTemplate string `json:"name_template,omitempty"` // Locations are the projects' own choices, by location. Locations map[string]SecretStoreChoice `json:"locations,omitempty"` } @@ -419,18 +432,71 @@ type SecretSettings struct { type SecretStoreChoice struct { Store string `json:"store,omitempty"` GCPProject string `json:"gcp_project,omitempty"` + // GCPPrivateProject is the box's alone: see SecretSettings. + GCPPrivateProject string `json:"gcp_private_project,omitempty"` + // NameTemplate says what a secret is called in a store that shares its + // names; Service is what it calls this project, its name by default. + NameTemplate string `json:"name_template,omitempty"` + Service string `json:"service,omitempty"` } func (c SecretStoreChoice) validate() error { if c.Store != "" && secretProviderByID(c.Store) == nil { return fmt.Errorf("there is no secret store %q", c.Store) } - if c.GCPProject != "" && !gcpProjectID.MatchString(c.GCPProject) { - return fmt.Errorf("%q is not a GCP project ID", c.GCPProject) + for _, p := range []string{c.GCPProject, c.GCPPrivateProject} { + if p != "" && !gcpProjectID.MatchString(p) { + return fmt.Errorf("%q is not a GCP project ID", p) + } + } + if c.NameTemplate != "" { + if err := validateNameTemplate(c.NameTemplate); err != nil { + return err + } + } + if c.Service != "" && !secretService.MatchString(c.Service) { + return fmt.Errorf("%q is not a service name: use letters, digits, - and _", c.Service) + } + return nil +} + +// A name template is a secret's name in a store that shares its names with +// other things, such as {NAME}_{SERVICE}_{ENV}. Written in capitals, a word +// comes out in capitals with underscores (SHOPIFY_BULK_EDITOR); in small +// letters, in small letters with hyphens (shopify-bulk-editor). +var ( + templateWord = regexp.MustCompile(`\{(NAME|SERVICE|ENV|name|service|env)\}`) + secretIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,255}$`) + secretService = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`) + notWordChars = regexp.MustCompile(`[^A-Za-z0-9]+`) +) + +func validateNameTemplate(t string) error { + if !strings.Contains(strings.ToLower(t), "{name}") { + return fmt.Errorf("the name template %q needs {NAME}", t) + } + if !secretIDPattern.MatchString(templateWord.ReplaceAllString(t, "X")) { + return fmt.Errorf("the name template %q can hold {NAME}, {SERVICE}, {ENV}, letters, digits, - and _", t) } return nil } +func templateNeeds(t, word string) bool { return strings.Contains(strings.ToLower(t), "{"+word+"}") } + +// secretID is name as template writes it. +func secretID(template, name, service, env string) string { + if template == "" { + return name + } + return templateWord.ReplaceAllStringFunc(template, func(w string) string { + v := map[string]string{"name": name, "service": service, "env": env}[strings.ToLower(strings.Trim(w, "{}"))] + if w == strings.ToUpper(w) { + return strings.ToUpper(notWordChars.ReplaceAllString(v, "_")) + } + return strings.ToLower(notWordChars.ReplaceAllString(v, "-")) + }) +} + // whereFor is the place a store id is opened at, from a choice. func (c SecretStoreChoice) whereFor(id string) string { if id == "gcp" { @@ -439,6 +505,14 @@ func (c SecretStoreChoice) whereFor(id string) string { return "" } +// privateFor is the place in store id set aside as private. +func (c SecretStoreChoice) privateFor(id string) string { + if id == "gcp" { + return c.GCPPrivateProject + } + return "" +} + // secretSettingsFile keeps the box's own choice. func (s *SecretStores) settingsPath() string { return filepath.Join(s.Dir, "secrets", "settings.json") } diff --git a/internal/box/secretstore_gcp.go b/internal/box/secretstore_gcp.go index 2877cdd4a..82bf75508 100644 --- a/internal/box/secretstore_gcp.go +++ b/internal/box/secretstore_gcp.go @@ -25,7 +25,7 @@ import ( func init() { registerSecretStore(&secretProvider{ ID: "gcp", Scheme: "gcp", Name: "GCP Secret Manager", - Caps: StoreCaps{Expiry: true, Labels: true, Grant: true}, + Caps: StoreCaps{Expiry: true, Labels: true, Grant: true, Template: true}, NeedsWhere: true, ValidateRef: validateGCPRef, WhereOf: func(ref string) string { @@ -141,12 +141,16 @@ func (g *gcpStore) Save(ctx context.Context, s SecretSave) (string, error) { if !secretName.MatchString(s.Name) { return "", fmt.Errorf("%q is not a secret name", s.Name) } + id := firstNonEmptyStr(s.ID, s.Name) + if !secretIDPattern.MatchString(id) { + return "", fmt.Errorf("%q can't be a Secret Manager name", id) + } if s.Grant != "" { if err := ValidateServiceAccount(s.Grant); err != nil { return "", err } } - existing, err := g.describe(ctx, s.Name) + existing, err := g.describe(ctx, id) switch { case errors.Is(err, errSecretNotFound): env := firstNonEmptyStr(s.Environment, "staging") @@ -157,23 +161,23 @@ func (g *gcpStore) Save(ctx context.Context, s SecretSave) (string, error) { if l := gcpLabel(g.env.Box); l != "" { labels = append(labels, "shipyard-box="+l) } - if _, err := g.gcloud(ctx, "secretmanager.secrets.create", "", "secrets", "create", s.Name, "--replication-policy=automatic", "--labels="+strings.Join(labels, ",")); err != nil { + if _, err := g.gcloud(ctx, "secretmanager.secrets.create", "", "secrets", "create", id, "--replication-policy=automatic", "--labels="+strings.Join(labels, ",")); err != nil { return "", err } case err != nil: return "", err case !existing.ours(): - return "", fmt.Errorf("project %s already has a secret called %s that Shipyard didn't make (it has no shipyard label), so it was left alone: pick another name, or label it shipyard=true to let Shipyard add versions", g.env.Where, s.Name) + return "", fmt.Errorf("project %s already has a secret called %s that Shipyard didn't make (it has no shipyard label), so it was left alone: pick another name, or label it shipyard=true to let Shipyard add versions", g.env.Where, id) } - if _, err := g.gcloud(ctx, "secretmanager.versions.add", s.Value, "secrets", "versions", "add", s.Name, "--data-file=-"); err != nil { + if _, err := g.gcloud(ctx, "secretmanager.versions.add", s.Value, "secrets", "versions", "add", id, "--data-file=-"); err != nil { return "", err } if s.Grant != "" { - if _, err := g.gcloud(ctx, "secretmanager.secrets.setIamPolicy", "", "secrets", "add-iam-policy-binding", s.Name, "--member=serviceAccount:"+s.Grant, "--role=roles/secretmanager.secretAccessor"); err != nil { - return "", fmt.Errorf("saved %s, but could not grant %s: %w", s.Name, s.Grant, err) + if _, err := g.gcloud(ctx, "secretmanager.secrets.setIamPolicy", "", "secrets", "add-iam-policy-binding", id, "--member=serviceAccount:"+s.Grant, "--role=roles/secretmanager.secretAccessor"); err != nil { + return "", fmt.Errorf("saved %s, but could not grant %s: %w", id, s.Grant, err) } } - return g.ref(s.Name), nil + return g.ref(id), nil } func (g *gcpStore) Resolve(ctx context.Context, ref string) (string, error) { @@ -204,7 +208,7 @@ func (g *gcpStore) List(ctx context.Context) ([]SecretMeta, error) { continue } id := s.Name[strings.LastIndex(s.Name, "/")+1:] - metas = append(metas, SecretMeta{Name: id, Ref: g.ref(id), Store: "gcp", StoreLabel: g.Label(), Where: g.env.Where, Environment: s.Labels["shipyard-environment"], Location: s.Labels["shipyard-location"], Created: s.CreateTime}) + metas = append(metas, SecretMeta{Name: id, ID: id, Ref: g.ref(id), Store: "gcp", StoreLabel: g.Label(), Where: g.env.Where, Environment: s.Labels["shipyard-environment"], Location: s.Labels["shipyard-location"], Created: s.CreateTime}) } return metas, nil }