diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c33db..ad1e857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,111 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-09-08 + +### Added + +- **The vault is now something an agent looks in.** Sixty-five keys were in it, + and the agent working in your repo still asked *you* for + `CLOUDFLARE_API_TOKEN` — or wrote `` and moved on, or + reported the variable as missing. Nothing refused it. It never looked, and + two things made that the rational move. A key was named only by slug + (`cf-gh-actions-deploy`), so the name code actually reads had no path to the + entry that holds it; the mapping existed as prose inside a `--purpose` + string, which is not an index. And an agent that did look dead-ended anyway, + because `get_key` is gated and there was no way to *use* a key without + reading it. A vault you cannot search and cannot spend gets worked around. + + So keys have a second name. `KeyEntry.env` is the environment variable the + key is exposed as — `CLOUDFLARE_API_TOKEN`, `NEON_API_KEY` — validated as + UPPER_SNAKE_CASE (A–Z, digits, `_`, starting with a letter, at most 64 + characters), which is the shape a shell and every dotenv parser accept. A + refusal spells the corrected name rather than restating the rule, so + `cf-token` comes back with "try `CF_TOKEN`" and the fix is a copy-paste. It + is optional on purpose: an Apple issuer id or a D-U-N-S number belongs in the + vault as something you would otherwise go hunting for, but it is not a + variable any program reads and inventing a name for it would be inventing a + fact. Two entries may share a name — a dev and a production + `LANGFUSE_SECRET_KEY` are two secrets under one identifier, which is the + normal shape — so nothing refuses it and a lookup returns both. A `keys.json` + written before the field existed parses unchanged, and the field is omitted + from the JSON when unset. + + The convention the docs now state, because a name only helps if it is the + name the other end already uses: **use the name code already reads.** If the + repo, the CI secret or the vendor's SDK spells it `CLOUDFLARE_API_TOKEN`, + that is the name; patchbay's job is to be findable by what exists, not to + impose a taxonomy on it. Only when nothing has named it yet do you compose + one as `__` — `NEON_API_KEY`, `APPLE_ASC_ISSUER_ID`, + `GITHUB_APP_PRIVATE_KEY`, `R2_SECRET_ACCESS_KEY` — with the kind drawn from + `_API_KEY`, `_API_TOKEN`, `_SECRET`, `_SECRET_KEY`, `_PRIVATE_KEY`, + `_PASSWORD`, `_ID`, `_URL`. The `id` stays the lowercase slug and stays what + you type in a command; `env` is what everything else searches by. + +- **`pb key run ... -- ` — spend a key without reading it.** The only + way a value came out of the vault was `pb key copy`, which goes to the + clipboard: right for a human with a browser tab open, useless to a process, + and not something an agent should be doing at all. `run` resolves each id, + takes the value from the keychain, and starts the command with those + variables added to the environment it inherits. The value goes keychain → + child and touches nothing else — not stdout, not a log, not argv, not a file, + and not the context of whatever model asked for it. stderr names the + variables and the ids they came from, so you can see *what* was injected + without seeing what was injected. `--as NAME=` covers the case where this + program wants a different name than the entry's, and an id with no `env` and + no `--as` is refused by name with the `pb key edit … --env` that fixes it, + because a run that silently dropped a credential fails somewhere much less + obvious. This is the second way a value leaves the vault, and the split is + deliberate: `copy` for the human, `run` for everything else. + +- **`pb key edit ` — metadata, and only metadata.** Every key registered + before this release has no `env` name, and a vault is worth the fraction of + it that is findable, so backfilling had to be one command that is obviously + safe to run. `edit` never opens the keychain and cannot rotate anything: + `--env NAME` / `--no-env`, plus `--provider`, `--label`, `--purpose`, + `--scopes`, `--expires`, `--endpoint`, with `--no-purpose`, `--no-expires` + and `--no-endpoint` to clear the nullable ones. `id`, `last4` and the + registration date stay uneditable — they describe the value in the keychain, + and editing them here would only make the registry lie about it. Rotation + stays `pb key add --overwrite`, where a secret is actually being handled and + the command should look like it. + +- **`pb key list` filters.** `--env NAME` answers "who holds the name my code + reads", `--provider P` narrows to one issuer, and `--grep TEXT` matches id, + label, purpose, provider and env name. The table gained an ENV column. A + listing you have to read in full is a listing an agent reads in full. + +- **MCP: `resolve_env_vars`, `update_key`, `list_keys` filters, and `env` on + `store_key`.** `resolve_env_vars` is the tool the two rules above hang off: + give it the variable names the code reads and it answers per name with `keys` + (exact `env` matches), `suggestions` when there are none (entries whose + purpose, label or id mention the name or its non-generic tokens), `projects` + (env-vault environments defining it), a `status` of `key` / `project_var` / + `both` / `suggested` / `missing`, and `use` — the exact `pb key run …` or + `pb env run …` that supplies it. It reads two local JSON files and returns no + value, which is why it is ungated, and it is the reason the answer to "I need + this credential" can be a command rather than a secret. `update_key` is + `pb key edit`'s twin over MCP — metadata only, with a `clear` list for + `purpose`, `expires_at`, `endpoint` and `env` — ungated for the same reason: + it cannot reach the keychain. Rotation stays `store_key` with `overwrite`. + `list_keys` takes `provider`, `env` and `query`. + +- **Two rules in the MCP server's instructions.** "LOOK HERE BEFORE ASKING FOR + A CREDENTIAL": before asking the human, writing a placeholder or calling a + variable missing, call `resolve_env_vars`; the answer is how to use the key + via `pb key run`, never the value itself. And "NAME THE VARIABLE": register + with `env` set, following the convention above, and backfill what lacks one + with `update_key`. Instructions are the only part of an MCP server a model + reads before deciding what to do, and every gate in this vault was already + strong enough — what was missing was the sentence telling it to try. + +### Changed + +- **The manifest's `KeyRecord` carries `env`.** `pb manifest` exists so a new + machine's agent can plan against what the old one used, and "which variable + is this key" is exactly the kind of thing that plan needs. It is a name, not + a value, so it changes nothing about what the file is safe to commit. + ## [0.6.0] - 2026-08-28 ### Added diff --git a/README.md b/README.md index c4f345a..bedfe3b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - **Switch** — change profile/context from the panel, the CLI, or an AI — including the traps (`gcloud` ADC). - **Permissions** — see what your tokens can actually do (`gh` scopes today) and fix missing scopes with one hint. - **[MCP client management](docs/mcp-clients.md)** — every MCP server registered in Claude Code, Claude Desktop, Cursor, Codex, Windsurf and VS Code in one matrix; copy a server between clients without hand-editing four files in two formats. -- **[Key vault](docs/key-vault.md)** — standalone API keys no CLI tracks: values in the macOS Keychain, metadata on disk, provider-aware `pb key verify`, and AI registration over MCP. +- **[Key vault](docs/key-vault.md)** — standalone API keys no CLI tracks: values in the macOS Keychain, metadata on disk, provider-aware `pb key verify`, AI registration over MCP, and each key filed under the env-var name your code already reads (`CLOUDFLARE_API_TOKEN`) so `pb key run` — or an agent that needs it — can find and inject it without anyone reading the value. - **[Project env vault](docs/env-vault.md)** — a project's environment variables without a plaintext `.env`: pull from Infisical, keep hand-set local overrides that never sync back, run a command with the merged result. A project is a portable name, not a path — `pb export` carries the manifest to a new machine (or copy the one file), clone the repo, pull. - **[Keeping CLIs current](#keeping-clis-current)** — which tools are outdated, which were renamed out from under you, and the exact command to update each one. - **[Migrate](docs/migration.md)** — export to a new machine; whatever can't travel, your AI walks you through re-authing. Or `pb manifest`: the secret-free record of what you use, safe to commit, and enough for an agent to rebuild a machine from. @@ -52,6 +52,7 @@ pb status # the whole board in your terminal pb use gcloud work # switch a profile pb verify gh # actually check a token against its API pb key list # your registered API keys +pb key run cf-deploy -- wrangler deploy # one credential into one process, never on screen pb env run -- bun dev # this directory's env vars, from the Keychain, no .env file ``` @@ -141,7 +142,7 @@ table →](docs/migration.md)** { "mcpServers": { "patchbay": { "command": "/usr/local/bin/patchbay-mcp" } } } ``` -Your agent gets `list_connections`, `switch_profile`, `verify`, `get_permissions`, `store_key`, `plan_setup`, and friends — "switch to the work gcloud account and deploy" becomes one sentence, and a key your AI creates mid-task gets registered instead of rotting in a chat log. Where permissions are granted per resource rather than per credential, `get_permissions` takes a `scope` and `list_permission_scopes` says what the choices are — a Google account has no roles of its own, only roles on a project, so patchbay reads the IAM policy of the one you name. Reading secret values back is **off by default** (`PATCHBAY_ALLOW_SECRET_READ=1` to opt in). +Your agent gets `list_connections`, `switch_profile`, `verify`, `get_permissions`, `store_key`, `plan_setup`, and friends — "switch to the work gcloud account and deploy" becomes one sentence, and a key your AI creates mid-task gets registered instead of rotting in a chat log. It also gets `resolve_env_vars`: hand it the variable names your code reads and it says which vault key or env-vault project holds each one, and the exact `pb key run` / `pb env run` that supplies it — so an agent that needs `CLOUDFLARE_API_TOKEN` looks in your vault instead of asking you for it or writing a placeholder, and still never sees the value. Where permissions are granted per resource rather than per credential, `get_permissions` takes a `scope` and `list_permission_scopes` says what the choices are — a Google account has no roles of its own, only roles on a project, so patchbay reads the IAM policy of the one you name. Reading secret values back is **off by default** (`PATCHBAY_ALLOW_SECRET_READ=1` to opt in). ## Showcase diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index ec585b1..1df93ed 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -187,6 +187,7 @@ async fn key_add( scopes: Vec, expires: Option, endpoint: Option, + env: Option, secret: String, overwrite: bool, ) -> CmdResult { @@ -212,7 +213,11 @@ async fn key_add( .collect(), ) .expires_at(expires_at) - .endpoint(some_text(endpoint)); + .endpoint(some_text(endpoint)) + // The variable name code reads this key as. Validated by the registry, + // not here, so the panel shows the same corrected-name suggestion the + // CLI does instead of a second opinion written in TypeScript. + .env(some_text(env)); let registry = KeyRegistry::detect()?; let entry = registry.add(new, &secret, overwrite)?; diff --git a/app/src/components/KeysView.tsx b/app/src/components/KeysView.tsx index 5332741..bebb65d 100644 --- a/app/src/components/KeysView.tsx +++ b/app/src/components/KeysView.tsx @@ -128,6 +128,7 @@ export function KeysView() { id provider + env label last 4 expiry @@ -141,6 +142,9 @@ export function KeysView() { {k.id} {k.provider} + {/* The second name: what code reads this key as, and what + `pb key run` injects it under. A name, never a value. */} + {k.env ?? —} {k.label} {/* The only thing on this page derived from a secret value. */} ··{k.last4} @@ -168,7 +172,7 @@ export function KeysView() { {confirming === k.id && ( - +
Remove {k.id}? This removes the entry and its keychain value; the @@ -228,6 +232,7 @@ function AddKeyForm({ onAdded: (row: KeyRow) => void | Promise; }>) { const [id, setId] = useState(""); + const [env, setEnv] = useState(""); const [provider, setProvider] = useState(""); const [label, setLabel] = useState(""); const [secret, setSecret] = useState(""); @@ -270,6 +275,7 @@ function AddKeyForm({ .filter(Boolean), expires: expires.trim() || null, endpoint: endpoint.trim() || null, + env: env.trim() || null, overwrite, }, value, @@ -322,6 +328,25 @@ function AddKeyForm({ + {/* Not behind the fold: this is the entry's second name, and the one + anything other than a human looks it up by. */} + +