From 8ddecc937edbe9798a991398b0cbb56494089996 Mon Sep 17 00:00:00 2001 From: Krish Garg <181051779+kgarg2468@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:50:24 +0000 Subject: [PATCH 1/2] readme: lead with the problem it solves, in plain words; no em dashes in the docs The README opens with what tokenstash is for: an agent needs your API keys but never needs to see them. Every technical term is explained the first time it appears, and remote access over Tailscale has its own section. The docs, the changelog, the issue templates and the before-and-after diagram lose every em dash, each sentence rewritten to read without one. Co-Authored-By: Claude --- .github/ISSUE_TEMPLATE/bug_report.md | 4 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- CHANGELOG.md | 24 +++--- CONTRIBUTING.md | 18 ++-- README.md | 121 +++++++++++++++------------ SECURITY.md | 22 ++--- docs/assets/before-after.svg | 2 +- docs/registry-verification.md | 68 +++++++-------- 8 files changed, 136 insertions(+), 125 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9c17e3a8..6ff85723 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,7 +3,7 @@ name: Bug report about: Something tokenstash did that it should not have, or did not do that it should --- -**Never paste a key value here** — not even a "test" one, not even partially. If the bug is that +**Never paste a key value here**, not even a "test" one, not even partially. If the bug is that a value showed up somewhere it should not have, that is a security report: use https://github.com/krishhgg/tokenstash/security/advisories/new instead of an issue. @@ -12,6 +12,6 @@ https://github.com/krishhgg/tokenstash/security/advisories/new instead of an iss - Agent, if one was involved (Claude Code / Codex / Cursor / Gemini CLI / none): **What you ran**, in order (`tokenstash doctor` output is useful; `tokenstash audit` is fine to -paste — it never contains values): +paste, because it never contains values): **What happened, and what you expected instead:** diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d7968e43..437df28d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,4 +2,4 @@ blank_issues_enabled: true contact_links: - name: Security report url: https://github.com/krishhgg/tokenstash/security/advisories/new - about: A value reaching an agent, an approval that should not have happened, anything from SECURITY.md — privately, please. + about: A value reaching an agent, an approval that should not have happened, anything from SECURITY.md. Report it privately, please. diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e0409c..cc879c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,11 +37,11 @@ - When `init` takes the old tokenstash sections out of an `AGENTS.md`, it saves every one you added or edited for `init --undo`, not only the first. With a record from an older version, `init --undo` takes out only an MCP entry shaped like the one init writes. A `TOKENSTASH_HOME` holding a backtick or a control character is refused, because the skill quotes that path for every agent session. A confirmed mode or MCP card that fails part way tries to put the setting and the registrations it changed back, and leaves a setting someone changed meanwhile; `tokenstash doctor` shows any agent left out of step. - A kernel keyring read takes the writers' lock before it moves a copy an older version left, so it cannot put an old key over a new one. `doctor` has an `older copies` line that fails when the two shared keyrings disagree and notes copies other sessions hold. A key imported while `need` generates one is kept. `run` also masks the lines of a private key held with escaped line breaks, as in a service-account JSON. -## 0.3.0 — explicit agent mode +## 0.3.0: explicit agent mode -**Explicit mode.** `tokenstash init --mode explicit` installs a command only you can invoke — `/tokenstash NAME` in Claude Code and Cursor (a skill with `disable-model-invocation: true`), `/prompts:tokenstash NAME` in Codex, `/tokenstash NAME` in Gemini CLI — and takes out the MCP server, the auto-loading skill and the AGENTS.md snippet, so nothing an agent reads unprompted mentions tokenstash. The command runs the CLI under the same rules. `init --mode auto` switches back and removes the commands; the choice is kept in `config.toml` (`agent_mode`) and shown by `doctor`, which also flags wiring left over from the other mode. `init --undo` restores either mode's files, and an MCP registration explicit mode took out that init had not made (the user's own `claude mcp add`, at user or local scope) comes back as that entry, not as a copy of the whole file. Once init's entry is out of a shared config, that file's whole-file undo record is dropped — whatever else it holds is yours, whether you put it there before the switch or after — and a registration of yours that init had replaced comes back as an entry. A file init created and left with nothing else in it is removed. Choosing the mode, `init --project` and `init --undo` are for a person at a terminal: an agent with a shell could otherwise put automatic mode back. `init --print-skill [--mode ...]` prints the skill text; `--print-snippet --mode explicit` prints an AGENTS.md section that keeps the no-paste and no-stand-in rules and says tokenstash runs only once the person invokes the command, for the rest of that task. The skill's env-file and stand-in rules now cover a configured env file and every unavailable key, not just `.env.local` and a declined one. A config that chose explicit mode does not load in 0.2 (unknown field); one that kept auto still does. +**Explicit mode.** `tokenstash init --mode explicit` installs a command only you can invoke, and takes out the MCP server, the auto-loading skill and the AGENTS.md snippet, so nothing an agent reads unprompted mentions tokenstash. The command is `/tokenstash NAME` in Claude Code and Cursor, through a skill with `disable-model-invocation: true`, `/prompts:tokenstash NAME` in Codex, and `/tokenstash NAME` in Gemini CLI. The command runs the CLI under the same rules. `init --mode auto` switches back and removes the commands; the choice is kept in `config.toml` (`agent_mode`) and shown by `doctor`, which also flags wiring left over from the other mode. `init --undo` restores either mode's files, and an MCP registration explicit mode took out that init had not made (the user's own `claude mcp add`, at user or local scope) comes back as that entry, not as a copy of the whole file. Once init's entry is out of a shared config, that file's whole-file undo record is dropped, because whatever else it holds is yours, whether you put it there before the switch or after. A registration of yours that init had replaced comes back as an entry. A file init created and left with nothing else in it is removed. Choosing the mode, `init --project` and `init --undo` are for a person at a terminal: an agent with a shell could otherwise put automatic mode back. `init --print-skill [--mode ...]` prints the skill text; `--print-snippet --mode explicit` prints an AGENTS.md section that keeps the no-paste and no-stand-in rules and says tokenstash runs only once the person invokes the command, for the rest of that task. The skill's env-file and stand-in rules now cover a configured env file and every unavailable key, not just `.env.local` and a declined one. A config that chose explicit mode does not load in 0.2 (unknown field); one that kept auto still does. -## 0.2.0 — trust v2: directories pair once +## 0.2.0: trust v2, directories pair once **Breaking** - Trust roots are retired. Nothing is trusted by folder: the first time a directory asks for stored keys you get one pairing card (Allow these / Allow these + any non-sensitive key here / Deny). `tokenstash trust add|list` print a notice; `trust rm` tidies old config; `init --trust` is accepted and ignored. @@ -52,29 +52,29 @@ **Migration** (automatic on first run) - Per-project approvals become grants for directories that still exist and were not re-created since the approval; bindings follow. Old tables are left in place, so a 0.1 binary can still open the database. -- A project that was silent only because of a trust root shows one pairing card — unless its `.env.local` already holds the same value (non-sensitive registry keys; the file must be yours, untracked and not a symlink). +- A project that was silent only because of a trust root shows one pairing card, unless its `.env.local` already holds the same value (non-sensitive registry keys; the file must be yours, untracked and not a symlink). **Hardening** - `tokenstash answer --allow` / `--allow-broad` now requires a person at a terminal, like `rotate`, `check`, `bind`, `workspaces`, `export` and `import`. An agent with a shell could otherwise read the card id from `tasks --json` and approve its own request. Denying is still open to anyone: it can only close the agent's own card. -- A card's text and link are the agent's, so they are treated as untrusted input: control, bidi and zero-width characters are stripped, the text is capped, and a link is stored only if it is `http(s)` — a `javascript:` link would have run in the inbox's own origin when clicked. For a key the registry knows, the provider's link wins over the agent's, so a card cannot point "Open …" at a lookalike page. Inbox pages carry a restrictive `Content-Security-Policy` and `Referrer-Policy: no-referrer`. +- A card's text and link are the agent's, so they are treated as untrusted input: control, bidi and zero-width characters are stripped, the text is capped, and a link is stored only if it is `http(s)`, because a `javascript:` link would have run in the inbox's own origin when clicked. For a key the registry knows, the provider's link wins over the agent's, so a card cannot point "Open …" at a lookalike page. Inbox pages carry a restrictive `Content-Security-Policy` and `Referrer-Policy: no-referrer`. - **Generated secrets are per project.** `JWT_SECRET`, `AUTH_SECRET`, `SESSION_SECRET`, `NEXTAUTH_SECRET` and `ENCRYPTION_KEY` were stored once per identity and shared by every project that asked, so a directory with a broad grant could receive another application's signing key. Each directory now generates its own (identity `-`); values already stored are untouched. - A generated secret's identity is never the caller's to choose: `identity: "default"` on `JWT_SECRET` from one project could hit the value an older tokenstash stored for another and, under a broad grant, deliver it without a card. A placeholder already in the env file (`changeme`) is not adopted, and approving a `run` card for a generatable name keeps the value the env file holds instead of minting a new one. - A denied pairing or sensitive card is honoured on every path: a broad grant given later (allow-broad on another card) used to deliver the denied key silently. - `tokenstash open` is human-only: it printed the full-scope inbox link to a pipe, which is an agent's context. `forget`, `list`, `audit`, `tasks --all` and `need --force` are human-only too; `answer` acts on another directory's card only for a person; `init` sets up the stash for anyone but registers itself as the agents' MCP server only for a person. -- A paste that other directories would receive — a Replace card, or a key they hold a grant for — needs the full inbox session or a person at the terminal. Without that, an agent in a hostile checkout could `report-bad` → `need` → `answer --stdin` its own value into every project holding the key. Closing an approval card from the paste-scope link needs the full session for the same reason. +- A paste that other directories would receive, such as a Replace card or a key they hold a grant for, needs the full inbox session or a person at the terminal. Without that, an agent in a hostile checkout could `report-bad` → `need` → `answer --stdin` its own value into every project holding the key. Closing an approval card from the paste-scope link needs the full session for the same reason. - A non-default `TOKENSTASH_HOME` gets its own keyring namespace, so re-homing tokenstash into a directory the caller controls finds an empty stash there, not the real keys under a database it can approve against. The default home is unchanged. - The directory name is treated as the agent's input on approval cards (control and bidi characters stripped), `TOKENSTASH_AGENT` is filtered wherever it is read, an agent-supplied `pattern` never overrides the registry's and must compile, a deny note that looks like a credential is refused, and `human_request.expects` is validated. - A FIFO at the env file or a `.gitignore` no longer hangs `need` or the MCP server; every read of those files checks for a regular file first. An indented `NAME=` line is now seen by every reader and replaced rather than duplicated. A `run` child never loads a symlinked env file. -- Bundles accept lowercase names and multi-line (PEM) values — the rules `need` already had — so an export is importable on the next machine. +- Bundles accept lowercase names and multi-line (PEM) values, the rules `need` already had, so an export is importable on the next machine. - One desktop notification per card (`need`, `ask` and the MCP tools alike): a polling agent used to re-notify on every call. - `need --timeout` / `ask --timeout` without `--blocking` is now an error instead of being silently ignored. `tokenstash inbox` exits 1 when it cannot bind for a reason other than "already running". `doctor` reports a Gemini CLI registration. The `/health` and `/api/tasks` inbox routes, which nothing called, are gone. - `bind NAME --identity` no longer applies to generatable names: their identity is always the directory's own. -- The crates build on macOS and Linux only; the Windows keyring dependency and the non-unix stubs are gone (the core never compiled there). `tokenstash-core` also lost `Db.conn` (crate-private), `Db::open_approval_task`, `Db::set_task_status`, `tasks::expire`, `envfile::ignore_line_covers` and `Config::default_trust_roots` — 0.2.0 is the first crates.io release, so nothing published depends on them. +- The crates build on macOS and Linux only; the Windows keyring dependency and the non-unix stubs are gone (the core never compiled there). `tokenstash-core` also lost `Db.conn` (crate-private), `Db::open_approval_task`, `Db::set_task_status`, `tasks::expire`, `envfile::ignore_line_covers` and `Config::default_trust_roots`. 0.2.0 is the first crates.io release, so nothing published depends on them. - Registry: `ALGOLIA_API_KEY`, `NEON_API_KEY`, `CLOUDINARY_URL`, `SLACK_BOT_TOKEN` and `DISCORD_BOT_TOKEN` are sensitive; `MAPBOX_ACCESS_TOKEN` is sensitive for `sk.` tokens; `DATABASE_URL` accepts `sqlite:`, `file:`, `libsql:`, `sqlserver:` and `redis:`; a `query:` probe percent-encodes the key; unknown registry fields are rejected by the registry test. -- **Nine tokens became sensitive**: `GITHUB_TOKEN`, `NPM_TOKEN`, `VERCEL_TOKEN`, `CLOUDFLARE_API_TOKEN`, `FLY_API_TOKEN`, `RAILWAY_TOKEN`, `UPSTASH_REDIS_REST_TOKEN`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_SECRET`. They can push code, publish packages, redeploy or read a database, so a broad grant no longer covers them — they get their own card per directory, as `DATABASE_URL` always did. +- **Nine tokens became sensitive**: `GITHUB_TOKEN`, `NPM_TOKEN`, `VERCEL_TOKEN`, `CLOUDFLARE_API_TOKEN`, `FLY_API_TOKEN`, `RAILWAY_TOKEN`, `UPSTASH_REDIS_REST_TOKEN`, `GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_SECRET`. They can push code, publish packages, redeploy or read a database, so a broad grant no longer covers them. They get their own card per directory, as `DATABASE_URL` always did. - A broad grant (or an on-disk match) no longer overrules a denial for that key in that directory. Generating a secret now needs the same one-time approval a delivery does when the request came from a program's output. - Answering a card is a compare-and-set: two answers racing can no longer turn a committed denial into an approval, and the loser writes nothing. -- Env file: the read-modify-write is locked, so two concurrent `need`s cannot drop a key. Newlines in a value are escaped — a pasted PEM key used to produce a line tokenstash could not read back, which made rotation silently skip that project and leave the old key on disk. A FIFO in place of the env file is refused. +- Env file: the read-modify-write is locked, so two concurrent `need`s cannot drop a key. Newlines in a value are escaped. A pasted PEM key used to produce a line tokenstash could not read back, which made rotation silently skip that project and leave the old key on disk. A FIFO in place of the env file is refused. - git is invoked with every `GIT_*` variable removed from its environment: `GIT_DIR` alone used to turn a tracked env file into an untracked one. A project inside a repo tokenstash will not adopt as a write root (a dotfiles repo at `$HOME`) now gets the ignore rule in its own `.gitignore` instead of none at all. If git cannot answer whether the file is ignored, the write is refused unless our own rule is provably the only one that applies. - Rotation reports every project it could not rewrite, including ones whose env file it could not resolve or read, and one failure no longer aborts the rest. - Revoking a workspace clears the v1 `approvals` rows too, so a 0.1 binary cannot keep delivering from them. A database written by a newer tokenstash is refused rather than half-understood. WAL sidecar files get the database's 0600. @@ -87,12 +87,12 @@ - Release: npm and PyPI publish the binaries built in that run rather than re-downloading them from the mutable GitHub release, publish jobs dropped `contents: write`, and the first npm publish carries provenance too. New `SECURITY.md`; CI runs tests, clippy and the leak test on every PR. The Homebrew formula reaches the tap only once the release is live, so a failed release no longer points `brew install` at files nobody can download. **New** -- `tokenstash workspaces list|revoke DIR|forget DIR` — which directories are paired with which keys (for a person at a terminal). Revoking never unwrites an env file. +- `tokenstash workspaces list|revoke DIR|forget DIR` shows which directories are paired with which keys, for a person at a terminal. Revoking never unwrites an env file. - On-disk equivalence: a copy that brought its `.env.local` along needs no card for that delivery; it grants nothing further, and rotation never follows it. - A directory deleted and re-created at the same path is detected (inode + birth time) and pairs again. - Every delivery is audited with the grant that allowed it (`tokenstash audit`, `--json`). - On npm, `tokenstash` is now a launcher plus per-platform binary packages (`tokenstash--`, chosen through `optionalDependencies`); there is no install script, so bun, pnpm 10 and `--ignore-scripts` installs work. `TOKENSTASH_BINARY` still overrides. -- PyPI: `uv tool install tokenstash` / `pipx install tokenstash` — wheels are the release binaries repacked, no Python code. The Homebrew tap (`kgarg2468/tokenstash/tokenstash`) is updated by the release workflow. +- PyPI: `uv tool install tokenstash` / `pipx install tokenstash`. The wheels are the release binaries repacked, no Python code. The Homebrew tap (`kgarg2468/tokenstash/tokenstash`) is updated by the release workflow. - Linux binaries are static (musl) and run on any distribution (DNS goes through musl's resolver: `/etc/resolv.conf` only, no NSS/mDNS); the 0.1 builds required glibc ≥ 2.39. The crates (`tokenstash`, `tokenstash-core`) now package correctly, so `cargo install tokenstash` works once they are on crates.io. - MCP results carry per-outcome `next` guidance; blocking calls are capped at 30 s; keys are re-verified with their provider before delivery (`verify_every`); `tokenstash export`/`import`, `export --from-env`, rotation (`rotate`, `check`, `report-bad`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99d7536d..07316c1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,23 +15,23 @@ } ``` -- `name` — the exact env var developers use. -- `url` — where a new user creates the key, as deep a link as possible. -- `steps` — what to click, in order. Write them as if for someone who has never used the product. -- `pattern` — regex for the key format, so a bad paste fails immediately. Optional but valuable. -- `check` — one cheap authenticated request that returns 200 with a valid key and 401 without. Optional. +- `name`: the exact env var developers use. +- `url`: where a new user creates the key, as deep a link as possible. +- `steps`: what to click, in order. Write them as if for someone who has never used the product. +- `pattern`: regex for the key format, so a bad paste fails immediately. Optional but valuable. +- `check`: one cheap authenticated request that returns 200 with a valid key and 401 without. Optional. `auth` is `bearer` | `header:` | `prefix:` | `basic-user` | `query:`; `method` defaults to GET. 403 is read as "live, lacks permission", never as a dead key; if the provider answers something other than 401 to a bad key (Google: 400), list it in `reject_status`. Set `at_use: true` only if - the request is free and read-only enough to run before every delivery — without it the check + the request is free and read-only enough to run before every delivery. Without it, the check runs at paste time only. -- `sensitive: true` — live payment keys, cloud credentials, anything with unbounded spend. These, +- `sensitive: true`: live payment keys, cloud credentials, anything with unbounded spend. These, and any name the registry does not know, get their own card per directory; the broad pairing button never covers them. Use `sensitive_pattern` when only some values are dangerous (e.g. Stripe live vs test). -- `generate: "base64:32"` — for local secrets with no vendor (`AUTH_SECRET`), so no human is involved. +- `generate: "base64:32"`: for local secrets with no vendor (`AUTH_SECRET`), so no human is involved. -Run `cargo test` — `registry_is_sane` validates every entry. +Run `cargo test`. `registry_is_sane` validates every entry. ## Code diff --git a/README.md b/README.md index f9ce5ffa..235be3b8 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@

tokenstash

- Paste a key once. Approve each directory. Keep secrets out of status output. + Your AI agent needs your API keys. It doesn't need to see them.

- A local credential broker for coding agents. When your agent needs an API key, it asks tokenstash instead of asking you to paste it into the chat. The key goes into your project's env file, and the agent gets back a status line instead of the key. + Ask Claude Code, Codex or Cursor to build anything that calls an API, and it stops to ask you to paste a key into the chat. Once you paste it, the key sits in the transcript, the session summary and the model's context. With tokenstash, the agent runs one command instead. The key goes straight into your project's .env.local, and the agent sees only ✓ OPENAI_API_KEY injected. +

+ +

+ Paste each key once, in your browser. Every project after that gets it with one click.
+ Works with Claude Code, Codex, Cursor and Gemini CLI. One Rust binary on your machine, with no account, no cloud service and no telemetry.

@@ -36,13 +41,13 @@ npm install -g tokenstash uv tool install tokenstash ``` -Then, once: +Then run this once: ```bash tokenstash init ``` -`init` picks your OS keychain and installs the tokenstash skill for the agents it finds: Claude Code, Codex, Gemini CLI and Cursor. Agents run the CLI, and nothing is added to your `AGENTS.md` or `CLAUDE.md`. macOS and Linux; Windows is not supported yet. +`init` picks your OS keychain, the password store your system already has (Keychain on macOS, a Secret Service such as GNOME Keyring on Linux). Then it installs the tokenstash skill for each agent it finds: Claude Code, Codex, Gemini CLI and Cursor. A skill is a short instruction file that an agent loads only when a task needs it, so tokenstash adds nothing to your `AGENTS.md` or `CLAUDE.md`. tokenstash runs on macOS and Linux. Windows is not supported yet.

Other ways to install @@ -54,7 +59,7 @@ pipx install tokenstash cargo install --locked --git https://github.com/krishhgg/tokenstash tokenstash ``` -Prebuilt binaries for macOS (Apple Silicon, Intel) and Linux (x64, arm64; static, any distribution) are on the [latest release](https://github.com/krishhgg/tokenstash/releases/latest), with sha256 files and a build attestation you can check with `gh attestation verify tokenstash-.tar.gz --repo krishhgg/tokenstash`. The npm and PyPI packages carry the same binary and run no install scripts. +Prebuilt binaries for macOS (Apple Silicon, Intel) and Linux (x64, arm64, static, any distribution) are on the [latest release](https://github.com/krishhgg/tokenstash/releases/latest). Each comes with a sha256 file and a build attestation, a signed record of which workflow built it, that you can check with `gh attestation verify tokenstash-.tar.gz --repo krishhgg/tokenstash`. The npm and PyPI packages carry the same binary and run no install scripts.
@@ -64,20 +69,20 @@ Prebuilt binaries for macOS (Apple Silicon, Intel) and Linux (x64, arm64; static The agent runs tokenstash need. If the key is stored and this folder is approved, it is written to .env.local and the agent gets a status line, not the value. If the key is stored but the folder isn't approved yet, you approve it once in the inbox on localhost. If the key isn't stored yet, you are notified with a link to a page on localhost, you paste it once, and it is stored and written.

-When the agent needs a key, it runs `tokenstash need OPENAI_API_KEY`. There are three outcomes: +When code needs a key, the agent runs `tokenstash need OPENAI_API_KEY`. One of three things happens: -- **You have the key and this folder is approved:** it's written to `.env.local` and the agent carries on. -- **You have the key but this folder hasn't been approved:** you get a notification. Open it, see exactly which keys would go into which file, and approve once. -- **You don't have the key yet:** you get a notification and a link to a page tokenstash serves on localhost, with the provider's signup link and the steps. Paste the key there once; it's stored in your OS keychain, and every later project can get it from there. +- **You have the key, and this folder is approved.** tokenstash writes it to `.env.local`, and the agent carries on. +- **You have the key, but this folder hasn't asked for it before.** You get a notification that opens a card in your inbox. The inbox is a page tokenstash serves only on your own machine, and a card is one request waiting for your answer. It shows exactly which keys go into which file, and you approve once. +- **You don't have the key yet.** The card links to the provider's sign-up page and lists the steps to create a key. You paste the key on the card once, and tokenstash keeps it in your OS keychain for every project after.

One key, several folders. The folder where you pasted it has it. The first time another folder asks, you approve it once in the inbox on localhost, and that folder is quiet for that key after. A folder that hasn't asked yet gets one approval when it does. Sensitive keys ask for each folder separately.

-- **Approval is per folder, and you choose how wide.** The first time a folder asks for keys you already have, you get three choices: **Allow these** (just the listed keys), **Allow these + any non-sensitive key here** (also future registry-known, non-sensitive keys), or **Deny** (remembered for 24 hours by default). -- **Sensitive keys always ask.** Live Stripe secret keys, AWS credentials, deploy and package-registry tokens, and any key tokenstash doesn't recognise get their own approval in each folder. The broad choice never covers them. -- **Local secrets are generated, not asked for.** `AUTH_SECRET`, `JWT_SECRET`, `SESSION_SECRET` and similar are created by tokenstash, one per folder. -- **Keys can be re-checked with the provider.** Keys whose provider supports an unattended check are re-checked before delivery when a check is due. A rejected key becomes a "replace this key" request; if the provider can't be reached, the key is delivered unchecked. +- **Approval is per folder, and you choose how wide it goes.** The first time a folder asks for keys you already have, the card offers three answers. **Allow these** covers only the listed keys. **Allow these + any non-sensitive key here** also covers keys tokenstash knows and rates as non-sensitive, now and later. **Deny** says no, and tokenstash remembers that for 24 hours by default. +- **Sensitive keys always ask.** Live Stripe secret keys, AWS credentials, deploy and package-registry tokens, and any key tokenstash doesn't recognise get their own approval in each folder. The broad answer never covers them. +- **Local secrets are generated, not asked for.** tokenstash creates `AUTH_SECRET`, `JWT_SECRET`, `SESSION_SECRET` and similar values itself, one per folder. +- **Keys are re-checked with the provider.** Before it hands over a key whose provider offers a free check, tokenstash asks the provider whether the key still works, at most once a day by default. A rejected key becomes a Replace card, so you paste a new one before your code gets a 401, the error a provider returns for a bad key. If the provider can't be reached, the key goes out unchecked. ## Where your key goes @@ -85,15 +90,15 @@ When the agent needs a key, it runs `tokenstash need OPENAI_API_KEY`. There are You paste the key on a page on localhost. It is stored in your OS keychain and written to the project's .env.local, which your app loads. From delivery, the agent gets a status line such as: OPENAI_API_KEY injected to .env.local, with no key value in it. It is not a sandbox: a shell in that folder can still read .env.local.

-The value goes from the page you paste it on, to your OS keychain, to the project's `.env.local` (mode `0600`, and added to `.gitignore`). `need` reports back with an exit code and a line like `✓ OPENAI_API_KEY injected → .env.local`, without the value, so delivering a key doesn't put it into the chat, a summary, or the conversation history. [`scripts/leak-test.sh`](scripts/leak-test.sh) runs the real binary with a canary key on every commit and fails if the canary appears on any of the surfaces it checks. +The value travels from the card you paste it on, to your OS keychain, to the project's `.env.local`. tokenstash makes that file readable only by you (mode `0600`) and adds it to `.gitignore`. `need` answers the agent with an exit code and a line like `✓ OPENAI_API_KEY injected → .env.local`, never the value. Delivering a key puts nothing into the chat, a summary or the conversation history. Every commit runs [`scripts/leak-test.sh`](scripts/leak-test.sh), which drives the real binary with a fake canary key and fails if the canary shows up anywhere it checks. -Your app loads `.env.local` the way it already would: frameworks such as Next.js read it on their own; for anything else, use your runtime's dotenv support, or start the program with `tokenstash run --`. +Your app loads `.env.local` the way it already does. Next.js and similar frameworks read it on their own. For anything else, use your runtime's dotenv support, the usual library for loading an env file, or start the program with `tokenstash run --`. -**It's not a sandbox.** An agent with a shell in an approved folder can read `.env.local`, just as it could without tokenstash, and anything you type into a request's note or answer field goes back to the agent as text. What goes away is the casual leak: the key pasted into chat or echoed back in a summary. The details are under [Security details](#more) below and in [SECURITY.md](SECURITY.md). +**It is not a sandbox.** An agent with a shell in an approved folder can still read `.env.local`, just as it could without tokenstash. Anything you type into a card's note or answer field also goes back to the agent as text. What tokenstash removes is the everyday leak, the key pasted into chat or echoed back in a summary. The details are under [Security details](#more) below and in [SECURITY.md](SECURITY.md). ## Using it -After `tokenstash init`, each agent has the tokenstash skill: it sees a one-line description of it in every session, and loads the full instructions (how to request a key, what each result means, how to debug tokenstash itself) when code needs a key. You can also run it yourself: +After `init`, every agent session sees one line that describes the tokenstash skill. The full instructions load only when code needs a key. They cover how to request a key, what each result means, and how to debug tokenstash itself. You never need a terminal, because every decision happens on a card in your browser. You can still run the CLI yourself: ```bash tokenstash need OPENAI_API_KEY RESEND_API_KEY # write the keys you have; ask for the rest @@ -102,33 +107,41 @@ tokenstash open # the inbox: everything waiting tokenstash doctor # check the setup ``` -### Automatic, or only when you say so +### Automatic, or only when you ask -By default the agent loads the skill on its own when code needs a key. All it carries into every session is the skill's one-line description; the instructions load only when they are used. If you would rather nothing happen until you ask: +By default the agent loads the skill on its own when code needs a key. All it carries into each session is the skill's one-line description. If you would rather nothing happen until you ask, run this: ```bash tokenstash init --mode explicit ``` -This installs the same skill marked for you to invoke: `/tokenstash OPENAI_API_KEY` in Claude Code and Cursor, `$tokenstash` in Codex. Gemini CLI reads the same skill as Codex and asks you before it loads any skill. Typed bare, it requests whatever the current task needs, and it covers the rest of that task: a key the work turns out to need later goes through it too. The value still goes to `.env.local` and never into the chat. In a session where you don't invoke it, Claude Code, Cursor and Codex do not see tokenstash at all, so the agent will ask you for keys the way it always did. `tokenstash init --mode auto` switches back, `doctor` shows the mode, and `init --undo` removes either. +This installs the same skill, marked so that only you can start it. You type `/tokenstash OPENAI_API_KEY` in Claude Code and Cursor, or `$tokenstash` in Codex. Gemini CLI reads the same skill as Codex and asks you before it loads any skill. Typed on its own, `/tokenstash` requests whatever the current task needs, and it covers the rest of that task, so a key the work needs later goes through it too. The value still goes to `.env.local` and never into the chat. In a session where you don't invoke it, Claude Code, Cursor and Codex do not see tokenstash at all, and the agent asks you for keys the way it always did. `tokenstash init --mode auto` switches back, `doctor` shows the mode, and `init --undo` removes either. + +### MCP, if you want it -Agents that prefer MCP can have it too: `tokenstash init --mcp` also registers tokenstash as an MCP server with each agent (auto mode only), and `--no-mcp` takes it out again. Earlier versions registered the server and wrote a section into `~/.codex/AGENTS.md` by default; `init` now takes both out unless you ask for the server. +MCP (Model Context Protocol) is the standard way agents plug in outside tools. Agents that prefer it can have it too. `tokenstash init --mcp` also registers tokenstash as an MCP server with each agent (automatic mode only), and `--no-mcp` takes it out again. Earlier versions registered the server and wrote a section into `~/.codex/AGENTS.md` by default. `init` now takes both out unless you ask for the server. + +## Working on another computer + +The inbox runs on the machine where tokenstash runs, at `127.0.0.1`, so its links open only on that machine. If you reach the machine over Tailscale, a private network between your own devices, run `tokenstash remote tailscale`, or let your agent run it. The inbox then also answers on the machine's Tailscale address, and every link points there. A link opened on any device signed in to your Tailscale account opens as you and can approve. Nothing else on the network gets an answer, and `tokenstash remote off` turns it off. + +Your agent knows when this matters. When it runs over SSH or on a machine with no desktop, every pending result tells it that you may be on another computer and how you can reach the card. Without Tailscale, you can forward port 7433 over SSH. ## Uninstall -1. **Take tokenstash out of your agents:** `tokenstash init --undo` (or ask your agent: you confirm the card). It removes the skill files `init` wrote, and from shared configs (`~/.claude.json`, `~/.codex/config.toml`, an `AGENTS.md`) it takes out only tokenstash's entry, putting back whatever was there under that name before. Anything else you added to those files since `init` stays. -2. **Optionally, remove your stored keys and data**: expand the section below. Do this after step 1, since the data directory holds the copies `init --undo` restores from. -3. **Remove the program:** `brew uninstall tokenstash`, or `npm uninstall -g tokenstash`, `uv tool uninstall tokenstash`, `pipx uninstall tokenstash`. +1. **Take tokenstash out of your agents** with `tokenstash init --undo`, or ask your agent and confirm the card it files. Undo removes the skill files `init` wrote. From shared configs such as `~/.claude.json`, `~/.codex/config.toml` and an `AGENTS.md`, it takes out only tokenstash's entry and puts back whatever was there under that name before. Anything else you added to those files since `init` stays. +2. **Remove your stored keys and data, if you want to.** Expand the section below. Do this after step 1, because the data directory holds the copies `init --undo` restores from. +3. **Remove the program** with `brew uninstall tokenstash`, `npm uninstall -g tokenstash`, `uv tool uninstall tokenstash` or `pipx uninstall tokenstash`.
Remove stored keys and all data -Do this for the default data directory and for every `TOKENSTASH_HOME` you have used; each one has its own keychain entries. +Do this for the default data directory and for every `TOKENSTASH_HOME` you have used. Each one has its own keychain entries. -1. Run `tokenstash list`, with `TOKENSTASH_HOME` set for a custom home and kept set for the next commands. It shows every key in that home's index, with its identity; generated secrets like `AUTH_SECRET` have one identity per folder. Remove each with `tokenstash forget NAME --identity IDENTITY`. Then run `tokenstash doctor` and note the stash backend it shows. -2. Stop any program you started with `tokenstash run`, close your agent sessions, then stop any tokenstash still running, for example with `pkill -x tokenstash` (which stops it for every home). A background inbox only exits on its own after 30 idle minutes with nothing waiting, and never if it was started with `--keep`. -3. Clear what the index didn't know about: a key whose index was lost earlier is still in the OS store. Search for `tokenstash` in Keychain Access on macOS or Passwords and Keys (Seahorse) on Linux, and delete tokenstash's entries; their service is `tokenstash` for the default home, or `tokenstash-` and eight hex characters for a custom one. On Linux you can also run `secret-tool clear service tokenstash` for the default home; it skips entries in a locked keyring, so unlock it in Passwords and Keys first. If `doctor` showed `keyutils`, the keys are in the kernel keyring and are gone after a reboot. -4. Delete the data directory: `~/.config/tokenstash` on Linux (`$XDG_CONFIG_HOME/tokenstash` if you set it), `~/Library/Application Support/tokenstash` on macOS, and any `TOKENSTASH_HOME` directory you used. +1. Run `tokenstash list`. For a custom home, set `TOKENSTASH_HOME` and keep it set for the next commands. The list shows every key in that home's index, with its identity. Generated secrets like `AUTH_SECRET` have one identity per folder. Remove each key with `tokenstash forget NAME --identity IDENTITY`. Then run `tokenstash doctor` and note the stash backend it shows. +2. Stop any program you started with `tokenstash run` and close your agent sessions. Then stop any tokenstash still running, for example with `pkill -x tokenstash`, which stops it for every home. A background inbox exits on its own only after 30 idle minutes with nothing waiting, and never if it was started with `--keep`. +3. Clear what the index didn't know about. A key whose index was lost earlier is still in the OS store. Search for `tokenstash` in Keychain Access on macOS, or in Passwords and Keys (Seahorse) on Linux, and delete tokenstash's entries. Their service is `tokenstash` for the default home, or `tokenstash-` followed by eight hex characters for a custom one. On Linux you can also run `secret-tool clear service tokenstash` for the default home. It skips entries in a locked keyring, so unlock that in Passwords and Keys first. If `doctor` showed `keyutils`, the keys are in the Linux kernel keyring and are gone after a reboot. +4. Delete the data directory. It is `~/.config/tokenstash` on Linux (`$XDG_CONFIG_HOME/tokenstash` if you set that) and `~/Library/Application Support/tokenstash` on macOS. Also delete any `TOKENSTASH_HOME` directory you used. Keys already written into projects' `.env.local` files stay there until you delete them. @@ -142,45 +155,43 @@ Keys already written into projects' `.env.local` files stay there until you dele | Command | What it does | | --- | --- | | `tokenstash need NAME… [--blocking]` | Write keys to the env file, or ask for them. Exit `0` written · `10` waiting on you · `20` denied · `30` expired · `1` error | -| `tokenstash ask "title" [--url URL] [--step STEP…]` | Ask you to do something only a person can do (a DNS record, a dashboard toggle, an OAuth consent screen) | +| `tokenstash ask "title" [--url URL] [--step STEP…]` | Ask you to do something only a person can do, such as a DNS record, a dashboard toggle or an OAuth consent screen | | `tokenstash open` · `tasks` · `answer [ID]` | See what's waiting on you and answer it, in the inbox or from the terminal | -| `tokenstash list` · `forget NAME [--identity ID]` · `rotate NAME` | Manage stored keys (never shows values) | -| `tokenstash bind NAME --identity ID` | Use a different identity (say, `work`) for one key in this folder | -| `tokenstash check` · `report-bad NAME` | Check keys with their providers; tell tokenstash a provider rejected one | -| `tokenstash workspaces [list\|revoke DIR\|forget DIR]` | Which folders are approved for which keys; take a folder's approvals away | +| `tokenstash list` · `forget NAME [--identity ID]` · `rotate NAME` | Manage stored keys. Values are never shown | +| `tokenstash bind NAME --identity ID` | Use a different identity, say `work`, for one key in this folder | +| `tokenstash check` · `report-bad NAME` | Check keys with their providers, or tell tokenstash a provider rejected one | +| `tokenstash workspaces [list\|revoke DIR\|forget DIR]` | See which folders are approved for which keys, and take a folder's approvals away | | `tokenstash export` · `import BUNDLE` | Move your stash to another machine in a passphrase-encrypted bundle | -| `tokenstash run -- COMMAND` | Run a program with `.env.local` loaded; see below | +| `tokenstash run -- COMMAND` | Run a program with `.env.local` loaded. See below | | `tokenstash remote [tailscale\|off]` | Open the inbox from your other devices over Tailscale | -| `tokenstash init [--mode auto\|explicit] [--mcp] [--undo]` · `doctor` · `audit` · `registry` | Install or remove the agent skill, automatic or on your command only, and optionally the MCP server; check the setup; see every delivery; list known providers | -| `tokenstash mcp` · `inbox` | The MCP server (after `init --mcp`) and the inbox (started for you) | +| `tokenstash init [--mode auto\|explicit] [--mcp] [--undo]` · `doctor` · `audit` · `registry` | Install or remove the agent skill and, if you want it, the MCP server. Check the setup, see every delivery, list known providers | +| `tokenstash mcp` · `inbox` | The MCP server (after `init --mcp`) and the inbox, which starts for you | -`tokenstash run` loads `.env.local` into the program's environment. If the program exits with an error and its output names a registry-known variable that isn't set, tokenstash asks for it and restarts the program once it arrives. A key requested this way needs your approval each time, even in an approved folder, since the program's output chose it. +`tokenstash run` loads `.env.local` into the program's environment. If the program exits with an error and its output names a variable tokenstash knows that isn't set, tokenstash asks for it and restarts the program once it arrives. A key requested this way needs your approval every time, even in an approved folder, because the program's output picked it. -You never need a terminal: everything you decide is on a card in your browser. When you ask your agent to replace or forget a key, use another identity in a project, ask again for a key you declined, or change how agents reach tokenstash, it runs the command and you confirm the card it files; nothing changes before you do. Approving, opening the full inbox and moving the stash between machines stay yours alone, and `list`, `audit` and `check` show an agent only its own folder. The details are in [SECURITY.md](SECURITY.md). +When you ask your agent to replace or forget a key, use another identity in a project, ask again for a key you declined, or change how agents reach tokenstash, it runs the command and files a card. Nothing changes until you confirm that card. Approving folders, opening the full inbox and moving the stash between machines stay yours alone. `list`, `audit` and `check` show an agent only its own folder. The details are in [SECURITY.md](SECURITY.md). -With `init --mcp`, agents that speak MCP also get six tools: `secrets_request`, `secrets_list`, `secrets_report_invalid`, `human_request`, `task_check` and `task_list`. The MCP server only acts for the folder your agent opened. +With `init --mcp`, agents that speak MCP also get six tools: `secrets_request`, `secrets_list`, `secrets_report_invalid`, `human_request`, `task_check` and `task_list`. The MCP server acts only for the folder your agent opened.
Configuration -`config.toml` lives in the data directory: `~/.config/tokenstash/` on Linux and `~/Library/Application Support/tokenstash/` on macOS, or wherever `TOKENSTASH_HOME` points. Every setting is optional. +`config.toml` lives in the data directory, which is `~/.config/tokenstash/` on Linux, `~/Library/Application Support/tokenstash/` on macOS, or wherever `TOKENSTASH_HOME` points. Every setting is optional. | Setting | Default | | | --- | --- | --- | | `env_file` | `.env.local` | the file keys are written to, relative to the project root | | `inbox_port` | `7433` | the localhost port the inbox uses | | `task_ttl_hours` | `24` | how long a request stays open, and how long a denial is remembered | -| `stash_backend` | `auto` | `keyring` (OS keychain), `keyutils` (Linux kernel keyring; cleared on reboot), `insecure-file` (plaintext, for CI only) | +| `stash_backend` | `auto` | `keyring` (OS keychain), `keyutils` (Linux kernel keyring, cleared on reboot) or `insecure-file` (plaintext, for CI only) | | `notifications` | `true` | desktop notifications | | `verify_every` | `24h` | how often a key is re-checked with its provider: `h`, `m`, `always` or `never` | -| `agent_mode` | `auto` | `auto`: agents load the skill when code needs a key; `explicit`: only when you invoke it. Set with `init --mode` | -| `mcp` | `false` | whether `init` registers the MCP server. Set with `init --mcp` / `--no-mcp` | - -The project is the git checkout you're in, so in a monorepo `apps/web` and `apps/api` share one `.env.local` at the repo root. A folder that isn't a checkout is its own project. +| `agent_mode` | `auto` | `auto` loads the skill when code needs a key, `explicit` only when you invoke it. Set with `init --mode` | +| `mcp` | `false` | whether `init` registers the MCP server. Set with `init --mcp` or `--no-mcp` | -**From another computer:** the inbox runs on the machine tokenstash runs on, at 127.0.0.1. If you reach that machine over Tailscale, `tokenstash remote tailscale` (your agent can run it) makes the inbox answer on its Tailscale address too, and every link point there; a link opened on any of your devices signed in to the same Tailscale account opens as you and can approve, and nothing else on the tailnet gets an answer. Without Tailscale, forward port 7433 over SSH. +The project is the git checkout you're in, so in a monorepo `apps/web` and `apps/api` share one `.env.local` at the repository root. A folder that isn't a checkout is its own project. **In a container** without a keychain, set `TOKENSTASH_STASH=insecure-file` (plaintext) or run tokenstash on the host. @@ -189,28 +200,28 @@ The project is the git checkout you're in, so in a monorepo `apps/web` and `apps
Security details -- **Delivery output never holds the key.** `need` and `secrets_request` return a status without the value. `tokenstash run --` is different by nature: it passes the program's own output through, with best-effort redaction of stored values. +- **Delivery output never holds the key.** `need` and `secrets_request` return a status without the value. `tokenstash run --` works differently, because it passes the program's own output through, with best-effort masking of stored values. - **What you type goes back to the agent.** A note or reason on a request, and the answer to an `ask`, reach the agent as text. Never put a key there. -- **The link an agent prints opens one request.** It can answer or decline that request and nothing else. Approving a folder, or confirming a change an agent asked for, needs your own inbox link, which reaches you only through the desktop notification (a card's "Send the link to my desktop" button sends it again) or `tokenstash open`. -- **Some places never receive a key:** your home directory itself, `/`, shared temporary directories, and tool and credential directories (`~/.ssh`, `~/.aws`, `~/.claude`, …). -- **A folder that already has the value needs no approval.** If its own untracked `.env.local` already holds the same value for a non-sensitive registry key, that delivery goes ahead without a card. -- **"A person at a terminal" is a heuristic.** An agent that fakes a terminal, or a process reading your keychain as you, is outside what tokenstash can stop. It guards the line between the agent's tools and you, not between programs running as you. +- **The link an agent prints opens one request.** It can answer or decline that request and nothing else. Approving a folder, or confirming a change an agent asked for, needs your own inbox link. That link reaches you only through the desktop notification or `tokenstash open`, and a card's "Send the link to my desktop" button sends it again. With Tailscale remote access on, a link opened on one of your own devices counts as yours. +- **Some places never receive a key.** These are your home directory itself, `/`, shared temporary directories, and tool and credential directories such as `~/.ssh`, `~/.aws` and `~/.claude`. +- **A folder that already has the value needs no approval.** If its own untracked `.env.local` already holds the same value for a non-sensitive key tokenstash knows, that delivery goes ahead without a card. +- **"A person at a terminal" is a best guess.** An agent that fakes a terminal, or another program reading your keychain as you, is outside what tokenstash can stop. It guards the line between the agent's tools and you, not between programs running as you. -What's in scope, what isn't, and how to report a problem: [SECURITY.md](SECURITY.md). +What's in scope, what isn't, and how to report a problem are in [SECURITY.md](SECURITY.md).
What it isn't -Not a vault: for that, use 1Password or Infisical. Not a proxy: tokenstash is never in the path of your API requests. Not discovery: it never reads `gh`, `aws`, Claude Code or Codex credentials. Not a sandbox: see [Where your key goes](#where-your-key-goes). +tokenstash is not a vault. For that, use 1Password or Infisical. It is not a proxy either, and it never sits in the path of your API requests. It does no discovery, so it never reads `gh`, `aws`, Claude Code or Codex credentials. And it is not a sandbox, as [Where your key goes](#where-your-key-goes) explains.
Adding a provider -[`crates/core/registry/providers.json`](crates/core/registry/providers.json) has one entry per key: its name, the provider, the signup page, the steps, the key's format, and an optional liveness check. Pull requests welcome; see [CONTRIBUTING.md](CONTRIBUTING.md) and the [registry verification record](docs/registry-verification.md). How well agents actually follow tokenstash's instructions is measured in [docs/agent-conformance.md](docs/agent-conformance.md). +[`crates/core/registry/providers.json`](crates/core/registry/providers.json) has one entry per key. Each entry holds the key's name, the provider, the sign-up page, the steps, the key's format and an optional check that the key still works. Pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) and the [registry verification record](docs/registry-verification.md). How well agents follow tokenstash's instructions is measured in [docs/agent-conformance.md](docs/agent-conformance.md).
diff --git a/SECURITY.md b/SECURITY.md index 4d40d83a..bcc806fc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ Use GitHub's private vulnerability reporting: **[Report a vulnerability](https:/ Please don't open a public issue for anything that would let one of the guarantees below be broken. -What helps: the version (`tokenstash --version`), your OS, and the smallest sequence of commands that shows the problem. A proof of concept is welcome but not required — a clear description of the path is enough. +What helps: the version (`tokenstash --version`), your OS, and the smallest sequence of commands that shows the problem. A proof of concept is welcome but not required. A clear description of the path is enough. Expect an acknowledgement within a few days. There is no bounty; this is a personal project. @@ -16,9 +16,9 @@ Expect an acknowledgement within a few days. There is no bounty; this is a perso These are the guarantees, each stated with its scope. Anything that breaks one within its scope is in scope for a report. Everything outside the scopes is listed under known limits below. -1. **Credential delivery does not echo the credential.** `tokenstash need` and the `secrets_request` MCP tool write a delivered value to the project's env file and return status only: exit codes, names, paths, task ids. The responses, errors and metadata of that workflow — stdout, stderr, MCP results, the SQLite index, the audit log, desktop notifications — do not carry credential values. `tokenstash run --` is different: it supplies the delivered values in the child's environment and forwards the child's own stdout and stderr, passed through best-effort redaction of exact stored values (limit below); what the child prints is the child's output, not tokenstash's. This is a statement about the delivery workflow's own outputs. It is not a proof that a value can never enter a model's context by another route: a note a person types is returned to the agent on purpose (limit below), the agent can read the env file it was given, and `export` writes every value, encrypted, to a file you name. -2. **A stored key is delivered to a directory only when that directory holds authorisation for it.** Before a value already in the stash is written into a directory, tokenstash checks, for that directory's workspace record: an exact grant for the key and identity, or an applicable broad grant (registry-confirmed non-sensitive keys, same identity), or that the directory's own env file already holds the identical value (a delivery check, not a grant). Otherwise it files an approval card, and only the full inbox session or a person at a terminal can approve it. A grant is created when an approval card is answered, or when a missing-key card for that directory is answered — and a missing-key card can be answered by whoever holds its card capability, including the agent that requested it, when no other directory holds a grant for that name at that moment. So "authorised" means the directory passed these checks; it does not mean a human supplied the value (limits below on provenance and on card capabilities). Denials are remembered for `task_ttl_hours`. -3. **Generated secrets are per directory and need no human.** `JWT_SECRET`, `AUTH_SECRET` and the like are minted locally, one per directory (identity `-`), and delivered without a card because no human ever holds them. Guarantee 1 covers them; guarantee 2 does not apply — a directory receives its own. +1. **Credential delivery does not echo the credential.** `tokenstash need` and the `secrets_request` MCP tool write a delivered value to the project's env file and return status only: exit codes, names, paths, task ids. The responses, errors and metadata of that workflow, meaning stdout, stderr, MCP results, the SQLite index, the audit log and desktop notifications, do not carry credential values. `tokenstash run --` is different: it supplies the delivered values in the child's environment and forwards the child's own stdout and stderr, passed through best-effort redaction of exact stored values (limit below); what the child prints is the child's output, not tokenstash's. This is a statement about the delivery workflow's own outputs. It is not a proof that a value can never enter a model's context by another route: a note a person types is returned to the agent on purpose (limit below), the agent can read the env file it was given, and `export` writes every value, encrypted, to a file you name. +2. **A stored key is delivered to a directory only when that directory holds authorisation for it.** Before a value already in the stash is written into a directory, tokenstash checks, for that directory's workspace record: an exact grant for the key and identity, or an applicable broad grant (registry-confirmed non-sensitive keys, same identity), or that the directory's own env file already holds the identical value (a delivery check, not a grant). Otherwise it files an approval card, and only the full inbox session or a person at a terminal can approve it. A grant is created when an approval card is answered, or when a missing-key card for that directory is answered, and a missing-key card can be answered by whoever holds its card capability, including the agent that requested it, when no other directory holds a grant for that name at that moment. So "authorised" means the directory passed these checks; it does not mean a human supplied the value (limits below on provenance and on card capabilities). Denials are remembered for `task_ttl_hours`. +3. **Generated secrets are per directory and need no human.** `JWT_SECRET`, `AUTH_SECRET` and the like are minted locally, one per directory (identity `-`), and delivered without a card because no human ever holds them. Guarantee 1 covers them; guarantee 2 does not apply, because a directory receives its own. 4. **Project identity is the canonical path plus a filesystem fingerprint, on a stable filesystem.** A workspace is keyed by its resolved path (symlinks and `..` resolved) and checked against a fingerprint of the directory: inode and birth time where the filesystem reports one; inode and device where it does not, which is weaker and flagged in `tokenstash workspaces`. A re-created directory is detected when the fingerprint changes. On a filesystem without birth times, a re-created directory that reuses the inode is not told apart. All of this assumes the paths hold still (stability limit below). 5. **The inbox is local-only and its credentials are separate.** It binds 127.0.0.1, validates the Host header, requires a credential plus a matching CSRF field on every write, and never renders a stored value. Three credentials: a persistent proof key that answers `/verify` with `HMAC(proof, tag‖nonce)` and is never placed in a URL, cookie or form; a persistent capability key that signs one link per card; and a browser session minted fresh each time an inbox process binds the port. The link an agent prints carries only a card capability, whatever the configuration says, and the session reaches you only through the desktop notification, `tokenstash open`, or your terminal (the retired `inbox_links = "full"` is covered below). 6. **The only network egress is a provider liveness probe** to a URL compiled into the registry, over TLS, with redirects off, run when a key is pasted and, if the provider marks it cheap enough, before a delivery. No telemetry, no accounts, no proxying of the agent's requests. @@ -27,18 +27,18 @@ These are the guarantees, each stated with its scope. Anything that breaks one w These are design boundaries, documented so you can judge them rather than discover them. -- **A local process running as you can read everything tokenstash can.** Anything with your uid can read the delivered env file, the child process environment, your keychain, `~/.config/tokenstash` (the proof and capability keys, and the browser session while an inbox runs), and can run `tokenstash` itself. tokenstash defends the *agent* boundary — what the tools and links it hands a model can do — not the *user* boundary. `TOKENSTASH_HOME` and `TOKENSTASH_STASH` are read from the environment of whatever runs it; a non-default home gets a keyring namespace of its own, so re-homing does not expose the real stash, but it is not a sandbox. +- **A local process running as you can read everything tokenstash can.** Anything with your uid can read the delivered env file, the child process environment, your keychain, `~/.config/tokenstash` (the proof and capability keys, and the browser session while an inbox runs), and can run `tokenstash` itself. tokenstash defends the *agent* boundary, meaning what the tools and links it hands a model can do, and not the *user* boundary. `TOKENSTASH_HOME` and `TOKENSTASH_STASH` are read from the environment of whatever runs it; a non-default home gets a keyring namespace of its own, so re-homing does not expose the real stash, but it is not a sandbox. - **An agent with a shell in a paired directory can read that directory's env file.** That is what delivery means. tokenstash decides *which* keys reach *which* directory; it cannot stop a process from reading a file it has been given. Scope keys per project, and use `--identity` to keep work and personal accounts apart. - **`tokenstash run --` redacts the child's output line by line, matching the exact stored value, best effort.** A program that prints a key in fragments, base64-encoded, reversed, or one character per line defeats it. Redaction is a courtesy; the approval gate is the control. - **The leak-test canary covers the paths the script exercises.** `scripts/leak-test.sh` drives the real binary and fails the build if the canary appears on any surface it checks. It is strong evidence, not a proof over all inputs: a surface no test drives is not covered. If you add an output surface, add it to that script (`CONTRIBUTING.md`). -- **A card capability is bearer authority for that card, not proof of who used it.** The link an agent prints opens one card — by an HMAC over the card's id, directory and creation time — and can answer or decline that card and nothing else: no other card, no approval, no closing an approval card, no full routes. Anyone holding the link can use it, including the agent it was printed to. It does not say who pasted the value. -- **The paste-into-a-shared-name check covers directories that hold a grant now, not future ones.** A card capability (or an agent at a shell) may not answer a paste that would reach another directory at that moment — a Replace card, or a name another directory already holds an exact or applicable-broad grant for. The check runs under the index write lock against the grants as they are then. It does not track where a value came from: a name nobody has granted yet can be seeded by the agent's paste, and a directory paired later with a broad grant receives that value on its next `need`. Do not rely on tokenstash to prove that a stored value came from you rather than from an agent. +- **A card capability is bearer authority for that card, not proof of who used it.** The link an agent prints opens one card, by an HMAC over the card's id, directory and creation time, and can answer or decline that card and nothing else: no other card, no approval, no closing an approval card, no full routes. Anyone holding the link can use it, including the agent it was printed to. It does not say who pasted the value. +- **The paste-into-a-shared-name check covers directories that hold a grant now, not future ones.** A card capability (or an agent at a shell) may not answer a paste that would reach another directory at that moment, such as a Replace card, or a name another directory already holds an exact or applicable-broad grant for. The check runs under the index write lock against the grants as they are then. It does not track where a value came from: a name nobody has granted yet can be seeded by the agent's paste, and a directory paired later with a broad grant receives that value on its next `need`. Do not rely on tokenstash to prove that a stored value came from you rather than from an agent. - **`inbox_links = "full"` is retired and no longer issues anything.** Older versions let this setting make `need`, `ask` and the MCP tools print the browser session, which put a credential that can approve into the agent's context. The value is still accepted so an old config loads, but it is ignored with a warning on stderr: agent links are scoped to one card regardless. A headless setup gets the full inbox by running `tokenstash open` in a terminal; there is no route to the session from agent output. -- **The session is rotated on every inbox start; links from before are dead, and the inbox cannot vouch for a raw URL.** A URL from a notification left in the tray, or a link in an old chat log, stops working once the inbox has restarted. A stale link that still carries a credential in its URL is answered with a page saying to get a fresh link from a new notification or `tokenstash open`; a browser holding only a stale cookie gets an empty 404. A card link that fails is refused outright — it is never upgraded by a session the browser happens to hold. The proof key and card capabilities persist across restarts, so card links keep opening their card and the CLI keeps recognising its own inbox; only the session rotates. If a squatter holds the port when you click an old link, the page you see is the squatter's: tokenstash's own surfaces refuse to send you to an unverified listener, but a bare loopback URL carries no proof of the listener's identity. -- **The desktop notification carries the full session.** That is deliberate — it is the channel that lets you approve — but it means your OS notification history is as trusted as your terminal. Turn notifications off (`notifications = false`) if that is not true for you. Anyone holding a card's link, the agent included, can press that card's "Send the link to my desktop" button; it shows the notification again (at most every 30 s per card) and returns nothing but whether the desktop took it, so the session still reaches only the desktop. +- **The session is rotated on every inbox start; links from before are dead, and the inbox cannot vouch for a raw URL.** A URL from a notification left in the tray, or a link in an old chat log, stops working once the inbox has restarted. A stale link that still carries a credential in its URL is answered with a page saying to get a fresh link from a new notification or `tokenstash open`; a browser holding only a stale cookie gets an empty 404. A card link that fails is refused outright. It is never upgraded by a session the browser happens to hold. The proof key and card capabilities persist across restarts, so card links keep opening their card and the CLI keeps recognising its own inbox; only the session rotates. If a squatter holds the port when you click an old link, the page you see is the squatter's: tokenstash's own surfaces refuse to send you to an unverified listener, but a bare loopback URL carries no proof of the listener's identity. +- **The desktop notification carries the full session.** That is deliberate, since it is the channel that lets you approve, but it means your OS notification history is as trusted as your terminal. Turn notifications off (`notifications = false`) if that is not true for you. Anyone holding a card's link, the agent included, can press that card's "Send the link to my desktop" button; it shows the notification again (at most every 30 s per card) and returns nothing but whether the desktop took it, so the session still reaches only the desktop. - **What an agent may ask for, and what it may not.** `forget`, `rotate`, `bind` and `init --mode`, `--mcp`, `--no-mcp` and `--undo`, run by an agent, file a card that changes nothing until the person confirms it with the full session; `rotate` from an agent files a Replace card and does not mark the key stale. `need --force` from an agent files one more card per key and identity in each project until the denial expires, and says on it that it is a second ask. `list`, `audit` and `check` show an agent only the keys and events of its own directory (the keys it received or was granted). The confirm cards are a product decision about who decides, not a sandbox: the card is the person's yes, and an agent that can pass the terminal check below can also run the person's command directly. -- **Human-only CLI commands rely on a terminal heuristic, not a credential.** The inbox's sensitive routes require possession of the session. The CLI commands that remain human-only — `answer --allow` (and confirming an action card), `open`, `tasks --all`, `workspaces`, `export`, `import` — and the person's own form of the card commands above require only that both standard streams are a TTY, no agent environment marker (`CLAUDECODE`, `CODEX_SANDBOX`, …) is set, and `TOKENSTASH_AGENT` is not set at all (setting it to `unknown` used to hide the other markers). They do not require holding any browser credential. A process that scrubs its environment and allocates a pseudo-terminal passes; `open` then prints the session and `export` writes the whole stash. This is a check against accidental use by an agent's ordinary shell, not an isolation boundary against a process running as you. -- **Notes are echoed to the agent, and the credential detector is incomplete.** A text answer to a human task, and the reason typed when declining or denying a card, go back to the agent verbatim — the inbox and the terminal say so before you type. A note is refused when it matches a recognisable credential shape (registry patterns and a few generic ones); an arbitrary password, an address, or any other private text is not recognised and will be sent. Secrets go through `tokenstash need`, never a note. A value pasted into a credential field may be sent once to its provider's registry endpoint to validate it (skippable per card) before it is stored and delivered. +- **Human-only CLI commands rely on a terminal heuristic, not a credential.** The inbox's sensitive routes require possession of the session. The CLI commands that remain human-only, which are `answer --allow` and confirming an action card, `open`, `tasks --all`, `workspaces`, `export` and `import`, and the person's own form of the card commands above require only that both standard streams are a TTY, no agent environment marker (`CLAUDECODE`, `CODEX_SANDBOX`, …) is set, and `TOKENSTASH_AGENT` is not set at all (setting it to `unknown` used to hide the other markers). They do not require holding any browser credential. A process that scrubs its environment and allocates a pseudo-terminal passes; `open` then prints the session and `export` writes the whole stash. This is a check against accidental use by an agent's ordinary shell, not an isolation boundary against a process running as you. +- **Notes are echoed to the agent, and the credential detector is incomplete.** A text answer to a human task, and the reason typed when declining or denying a card, go back to the agent verbatim, and the inbox and the terminal say so before you type. A note is refused when it matches a recognisable credential shape (registry patterns and a few generic ones); an arbitrary password, an address, or any other private text is not recognised and will be sent. Secrets go through `tokenstash need`, never a note. A value pasted into a credential field may be sent once to its provider's registry endpoint to validate it (skippable per card) before it is stored and delivered. - **The env file cannot be *silently* committed; "cannot ever be committed" is not claimed.** At write time tokenstash refuses a git-tracked env file, refuses one the repo does not ignore, adds the name to `.gitignore` when it can, and refuses the write if git cannot be consulted. Nothing stops `git add -f` afterwards, a later edit to `.gitignore`, or a commit from a tool that does not consult these checks. - **Directory and path checks assume a stable filesystem.** Directory identity and path protections assume project directories, ancestors, and env-file paths remain stable during bound sessions and delivery. Concurrent filesystem replacement can invalidate these checks. They do not isolate tokenstash from a hostile process running as the same user. The gaps are between authorising a delivery and writing the resolved path, between a containment check and the temp-file rename, between reading an env file's owner and inode for the already-on-disk check and opening it, and between an MCP server binding a directory and a later use of that path. Anchoring authorisation, read and write to one held handle is a follow-up, not a shipped protection. A directory identified without a birth time (inode and device) is a weaker identity that a re-created directory can reuse. - **Remote access over Tailscale trusts the owner's devices.** With `remote = "tailscale"` the inbox also listens on this machine's Tailscale address. A request from another device that Tailscale reports (`tailscale whois`) as signed in with `remote_login` is treated as the person: it gets the full session, on any route, without a link credential. A request from this machine's own Tailscale address is treated as loopback (it needs a credential), and a request from any other login or a tagged device gets nothing. So anyone who can use one of your Tailscale-signed-in devices can approve, and an agent that can run commands on another of your devices can too; an agent confined to this machine cannot, since it cannot send from another device's address. Posts still need the session cookie and the matching hidden field, so a web page open in your browser cannot answer a card. The traffic is plain HTTP inside Tailscale's encrypted tunnel. The tailnet listener checks the Host header against this machine's Tailscale name and address, and reads the setting on every request (a setting it cannot read counts as off), so `tokenstash remote off` takes effect at once. The ownership check (`/verify`) answers on loopback, and on the tailnet only to this machine itself; links and notifications name the Tailscale address only when that check, made again each time a command prints links, proves the inbox there is ours, so a process that took the address and port first gets no links, and `tokenstash remote tailscale` fails and says so. The owner is the login this machine is signed in with; only on a tagged node, which has none, may an agent name one with `--login`, and naming another login on a signed-in machine is a person's command. A `tailscale whois` that fails or does not answer within three seconds counts as no login, and the next request asks again. Release builds run `tailscale` only from where Tailscale installs it (on Linux `/usr/bin`, `/usr/sbin`, `/usr/local/bin` or the NixOS system profile; on macOS the app, `/usr/local/bin` or `/opt/homebrew/bin`), with a fixed `PATH` and none of the caller's environment except `HOME`, so neither `PATH` nor `TOKENSTASH_TAILSCALE` (read by debug builds only, for tests) lets an agent pick the program that says who sent a request. This does not stop a process running as you from replacing that program where you can write to it (Homebrew's directories usually belong to you), or from reading the session file (first limit above). Turning it on does not need a card, since it is how the person reaches the inbox at all; an agent can run it. diff --git a/docs/assets/before-after.svg b/docs/assets/before-after.svg index 5d2d56dc..fca46eb6 100644 --- a/docs/assets/before-after.svg +++ b/docs/assets/before-after.svg @@ -41,7 +41,7 @@ ✓ OPENAI_API_KEY injected → .env.local ✓ RESEND_API_KEY injected → .env.local - ✓ TAVUS_API_KEY pending — task t_7fa2 + ✓ TAVUS_API_KEY pending, card t_7fa2 → http://127.0.0.1:7433/… Keys land in .env.local. The output never holds a key. diff --git a/docs/registry-verification.md b/docs/registry-verification.md index 1370c888..497d1a13 100644 --- a/docs/registry-verification.md +++ b/docs/registry-verification.md @@ -11,7 +11,7 @@ probes with a constant string that is not a key for anything. ## What was checked -**URLs.** Every `url` — the page a human is sent to in order to create the key — +**URLs.** Every `url`, the page a human is sent to in order to create the key, was fetched with redirects followed and a browser User-Agent. The final URL is recorded as well as the status, because the failure mode that matters is not a 404: it is a dead deep link that the provider quietly 302s onto its marketing @@ -23,7 +23,7 @@ the user lands on the right page after signing in. Those rows read "login wall" and were left alone. Likewise a 401/403 from a bot-protected marketing edge. **Checks.** Every `check` was called with an obviously invalid credential using -the exact auth style the registry declares — `apply_auth` in the script mirrors +the exact auth style the registry declares. `apply_auth` in the script mirrors `crates/core/src/validate.rs` line for line, so what was probed is what the binary sends. The bar is that an invalid key must produce the provider's documented auth-failure status. A 404 means the endpoint is wrong; a 400 about @@ -75,7 +75,7 @@ one, so the status is about the credential and not about the request shape. They now carry `reject_status`, a new optional `Check` field for exactly this. Sending an `AIza` key to Google as a bearer token does produce a 401, but only -because Google then reads it as an OAuth token — a *valid* key would be rejected +because Google then reads it as an OAuth token, so a *valid* key would be rejected too. That is worse than no check, so the auth style was left as the API-key style it should be. @@ -93,15 +93,15 @@ by leaving a dead probe in the file. `GEMINI_API_KEY` and `GOOGLE_API_KEY` required `^AIza`. Google AI Studio now issues *auth keys* by default, which are `AQ.`-prefixed, and Google's docs state the Gemini API "will reject requests from Standard keys" from September 2026. -So `^AIza` is not merely rejecting new keys — it is on track to reject every key +So `^AIza` is not merely rejecting new keys. It is on track to reject every key that still works. The prefix itself is not printed in Google's documentation, only confirmed by Google staff on Google's own forum, so the pattern now accepts both rather than switching outright. `GOOGLE_MAPS_API_KEY` keeps `^AIza`: Maps Platform is a separate key system and is unaffected. Second worst: `GITHUB_TOKEN` rejected `ghs_`, which is exactly what GitHub -Actions' built-in `GITHUB_TOKEN` is — GitHub's docs call it a GitHub App -installation access token — so the pattern fired on the single most common value +Actions' built-in `GITHUB_TOKEN` is, a GitHub App +installation access token in GitHub's own docs, so the pattern fired on the single most common value that variable ever holds. ### Left alone, on purpose @@ -120,7 +120,7 @@ documented shape at all, so `^pat` was asserting something Airtable explicitly declines to promise. A handful of patterns are marked OK on the strength of official *examples* -rather than official prose — Groq, DeepSeek, Perplexity, Firecrawl. The prefix +rather than official prose. They are Groq, DeepSeek, Perplexity and Firecrawl. The prefix is what the provider prints in its own quickstart, but no document guarantees it. They are correct today and are noted here so the next person knows the evidence is one notch weaker than a spec. @@ -136,42 +136,42 @@ probe, which is most of them. | name | url | check | pattern verdict | action taken | source | | --- | --- | --- | --- | --- | --- | -| `OPENAI_API_KEY` | OK (403, login or bot wall) | OK (401) | OK `^sk-` — sk-proj-/sk-svcacct-/sk-admin- all keep it | none | [docs](https://developers.openai.com/api/docs/guides/admin-apis) | -| `ANTHROPIC_API_KEY` | OK (200) | OK (401) | OK `^sk-ant-` — api03/admin01/api01 all keep it | url -> platform.claude.com/settings/keys | [docs](https://platform.claude.com/docs/en/manage-claude/admin-api-keys) | -| `GEMINI_API_KEY` | OK (200, login wall) | OK (400, declared) | LOOSENED `^AIza` -> `^(AIza\|AQ\.)` — AI Studio now issues AQ. auth keys | pattern loosened; check declares reject_status 400 | [docs](https://ai.google.dev/gemini-api/docs/api-key) | +| `OPENAI_API_KEY` | OK (403, login or bot wall) | OK (401) | OK `^sk-`, sk-proj-/sk-svcacct-/sk-admin- all keep it | none | [docs](https://developers.openai.com/api/docs/guides/admin-apis) | +| `ANTHROPIC_API_KEY` | OK (200) | OK (401) | OK `^sk-ant-`, api03/admin01/api01 all keep it | url -> platform.claude.com/settings/keys | [docs](https://platform.claude.com/docs/en/manage-claude/admin-api-keys) | +| `GEMINI_API_KEY` | OK (200, login wall) | OK (400, declared) | LOOSENED `^AIza` -> `^(AIza\|AQ\.)`, AI Studio now issues AQ. auth keys | pattern loosened; check declares reject_status 400 | [docs](https://ai.google.dev/gemini-api/docs/api-key) | | `GOOGLE_API_KEY` | OK (200, login wall) | OK (400, declared) | LOOSENED, same as GEMINI_API_KEY | pattern loosened; check declares reject_status 400 | [docs](https://ai.google.dev/gemini-api/docs/api-key) | | `GROQ_API_KEY` | OK (200) | OK (401) | OK `^gsk_` (docs show the prefix in examples only) | none | [docs](https://console.groq.com/docs/production-readiness/security-onboarding) | | `MISTRAL_API_KEY` | OK (307, login wall) | OK (401) | no pattern | none | | -| `OPENROUTER_API_KEY` | OK (200, login wall) | OK (401) | OK `^sk-or-` — keys are sk-or-v1- | none | [docs](https://openrouter.ai/docs/features/provisioning-api-keys) | +| `OPENROUTER_API_KEY` | OK (200, login wall) | OK (401) | OK `^sk-or-`, keys are sk-or-v1- | none | [docs](https://openrouter.ai/docs/features/provisioning-api-keys) | | `XAI_API_KEY` | OK (403, login or bot wall) | OK (400, declared) | OK `^xai-` | check declares reject_status 400 (xAI answers 400, not 401) | [docs](https://docs.x.ai/developers/rest-api-reference/management/auth) | | `DEEPSEEK_API_KEY` | OK (202) | OK (401) | OK `^sk-` (examples only) | none | [docs](https://api-docs.deepseek.com/) | | `TOGETHER_API_KEY` | OK (200, login wall) | OK (401) | no pattern | none | | -| `FIREWORKS_API_KEY` | OK (200) | none | LOOSENED `^fw_` -> `^(fw_\|fpk_)` — Fire Pass keys are fpk_ | url 404 -> app.fireworks.ai/settings/users/api-keys | [docs](https://docs.fireworks.ai/getting-started/quickstart) | +| `FIREWORKS_API_KEY` | OK (200) | none | LOOSENED `^fw_` -> `^(fw_\|fpk_)`, Fire Pass keys are fpk_ | url 404 -> app.fireworks.ai/settings/users/api-keys | [docs](https://docs.fireworks.ai/getting-started/quickstart) | | `PERPLEXITY_API_KEY` | OK (403, login or bot wall) | none | OK `^pplx-` (examples only) | none | [docs](https://docs.perplexity.ai/docs/admin/api-key-management) | | `COHERE_API_KEY` | OK (200, login wall) | OK (401) | no pattern | none | | -| `HF_TOKEN` | OK (200, login wall) | OK (401) | OK `^hf_` — hf_jwt_/hf_oauth_ keep it | none | [docs](https://huggingface.co/docs/hub/trusted-publishers) | -| `REPLICATE_API_TOKEN` | OK (200, login wall) | OK (401) | OK `^r8_` — docs state 40 chars always starting r8_ | none | [docs](https://replicate.com/docs/topics/security/api-tokens) | +| `HF_TOKEN` | OK (200, login wall) | OK (401) | OK `^hf_`, hf_jwt_/hf_oauth_ keep it | none | [docs](https://huggingface.co/docs/hub/trusted-publishers) | +| `REPLICATE_API_TOKEN` | OK (200, login wall) | OK (401) | OK `^r8_`, docs state 40 chars always starting r8_ | none | [docs](https://replicate.com/docs/topics/security/api-tokens) | | `FAL_KEY` | OK (200, login wall) | none | no pattern | none | | | `ELEVENLABS_API_KEY` | OK (200) | OK (401) | no pattern | url -> /app/developers/api-keys | | | `DEEPGRAM_API_KEY` | OK (200) | OK (401) | no pattern | none | | | `TAVUS_API_KEY` | OK (403, login or bot wall) | none | no pattern | none | | -| `TAVILY_API_KEY` | OK (200) | none | OK `^tvly-` — tvly-dev-/prod keep it | url -> app.tavily.com/home | [docs](https://docs.tavily.com/documentation/quickstart) | +| `TAVILY_API_KEY` | OK (200) | none | OK `^tvly-`, tvly-dev-/prod keep it | url -> app.tavily.com/home | [docs](https://docs.tavily.com/documentation/quickstart) | | `EXA_API_KEY` | UNVERIFIED (429 bot challenge) | none | no pattern | UNVERIFIED url: 429 Vercel bot challenge, unfetchable; docs confirm the URL | [docs](https://exa.ai/docs/reference/getting-started) | | `SERPER_API_KEY` | OK (200) | none | no pattern | url 404 -> serper.dev/api-keys (plural) | UNVERIFIED: serper.dev has no docs site; route confirmed live | | `FIRECRAWL_API_KEY` | OK (200, login wall) | none | OK `^fc-` (examples only) | none | [docs](https://docs.firecrawl.dev/api-reference/introduction) | | `BRAVE_API_KEY` | OK (200, login wall) | OK (422, declared) | no pattern | check declares reject_status 422 (Brave answers 422 SUBSCRIPTION_TOKEN_INVALID) | [docs](https://api-dashboard.search.brave.com/app/documentation) | -| `STRIPE_SECRET_KEY` | OK (200, login wall) | OK (401) | LOOSENED `^(sk\|rk)_(test\|live)_` -> `^(sk\|rk)_` — org keys are sk_org_ with no infix | pattern + sensitive_pattern widened to cover sk_org_ | [docs](https://docs.stripe.com/keys/organization-api-keys) | -| `STRIPE_PUBLISHABLE_KEY` | OK (200, login wall) | none | OK `^pk_(test\|live)_` — organizations have no publishable keys | none | [docs](https://docs.stripe.com/keys) | +| `STRIPE_SECRET_KEY` | OK (200, login wall) | OK (401) | LOOSENED `^(sk\|rk)_(test\|live)_` -> `^(sk\|rk)_`, org keys are sk_org_ with no infix | pattern + sensitive_pattern widened to cover sk_org_ | [docs](https://docs.stripe.com/keys/organization-api-keys) | +| `STRIPE_PUBLISHABLE_KEY` | OK (200, login wall) | none | OK `^pk_(test\|live)_`, organizations have no publishable keys | none | [docs](https://docs.stripe.com/keys) | | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | OK (200, login wall) | none | OK, same as STRIPE_PUBLISHABLE_KEY | none | [docs](https://docs.stripe.com/keys) | -| `STRIPE_WEBHOOK_SECRET` | OK (200, login wall) | none | OK `^whsec_` — also v2 event destinations | none | [docs](https://docs.stripe.com/webhooks) | +| `STRIPE_WEBHOOK_SECRET` | OK (200, login wall) | none | OK `^whsec_`, also v2 event destinations | none | [docs](https://docs.stripe.com/webhooks) | | `RESEND_API_KEY` | OK (200, login wall) | OK (400, declared) | OK `^re_` | check declares reject_status 400 (Resend answers 400) | [docs](https://resend.com/docs/api-reference/introduction) | | `SENDGRID_API_KEY` | OK (200) | OK (401) | OK `^SG\.` | none | [docs](https://www.twilio.com/docs/sendgrid/api-reference/api-keys/create-api-keys) | | `POSTMARK_SERVER_TOKEN` | OK (202, login wall) | OK (401) | no pattern | none | | -| `TWILIO_ACCOUNT_SID` | OK (200) | none | OK `^AC` — docs give ^AC[0-9a-fA-F]{32}$ | none | [docs](https://www.twilio.com/docs/iam/api/account) | +| `TWILIO_ACCOUNT_SID` | OK (200) | none | OK `^AC`, docs give ^AC[0-9a-fA-F]{32}$ | none | [docs](https://www.twilio.com/docs/iam/api/account) | | `TWILIO_AUTH_TOKEN` | OK (200) | none | no pattern | none | | | `SUPABASE_URL` | OK (200) | none | OK project-URL shape | url -> Data API overview (Settings -> API was split) | [docs](https://supabase.com/docs/guides/api) | | `NEXT_PUBLIC_SUPABASE_URL` | OK (200) | none | OK project-URL shape | url -> Data API overview | [docs](https://supabase.com/docs/guides/api) | -| `SUPABASE_ANON_KEY` | OK (200) | none | OK `^(eyJ\|sb_publishable_)` — legacy JWT valid to end of 2026 | url -> Settings -> API Keys | [docs](https://supabase.com/docs/guides/api/api-keys) | +| `SUPABASE_ANON_KEY` | OK (200) | none | OK `^(eyJ\|sb_publishable_)`, legacy JWT valid to end of 2026 | url -> Settings -> API Keys | [docs](https://supabase.com/docs/guides/api/api-keys) | | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | OK (200) | none | OK, same as SUPABASE_ANON_KEY | url -> Settings -> API Keys | [docs](https://supabase.com/docs/guides/api/api-keys) | | `SUPABASE_SERVICE_ROLE_KEY` | OK (200) | none | OK `^(eyJ\|sb_secret_)` | url -> Settings -> API Keys | [docs](https://supabase.com/docs/guides/api/api-keys) | | `DATABASE_URL` | OK (200, login wall) | none | OK connection-scheme list | url neon.tech (now marketing) -> console.neon.tech/app/projects | [docs](https://neon.com/faqs/find-database-connection-string) | @@ -179,9 +179,9 @@ probe, which is most of them. | `UPSTASH_REDIS_REST_URL` | OK (200) | none | OK `^https://` | none | | | `UPSTASH_REDIS_REST_TOKEN` | OK (200) | none | no pattern | none | | | `VERCEL_TOKEN` | OK (200, login wall) | OK (403) | no pattern | none | | -| `GITHUB_TOKEN` | OK (200, login wall) | OK (401) | LOOSENED -> `^(ghp_\|gho_\|ghu_\|ghs_\|ghr_\|github_pat_)` — Actions' own GITHUB_TOKEN is ghs_ | pattern loosened | [docs](https://docs.github.com/en/actions/concepts/security/github_token) | +| `GITHUB_TOKEN` | OK (200, login wall) | OK (401) | LOOSENED -> `^(ghp_\|gho_\|ghu_\|ghs_\|ghr_\|github_pat_)`, Actions' own GITHUB_TOKEN is ghs_ | pattern loosened | [docs](https://docs.github.com/en/actions/concepts/security/github_token) | | `CLOUDFLARE_API_TOKEN` | OK (403, login or bot wall) | OK (401) | no pattern | none | | -| `AWS_ACCESS_KEY_ID` | OK (200, login wall) | none | OK `^(AKIA\|ASIA)` — ABIA/ACCA are not access key IDs | none | [docs](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html) | +| `AWS_ACCESS_KEY_ID` | OK (200, login wall) | none | OK `^(AKIA\|ASIA)`, ABIA/ACCA are not access key IDs | none | [docs](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html) | | `AWS_SECRET_ACCESS_KEY` | OK (200, login wall) | none | no pattern | none | | | `CLERK_SECRET_KEY` | OK (404 unauthenticated; docs-canonical) | none | OK `^sk_(test\|live)_` | url -> dashboard.clerk.com/~/api-keys (bare host 404s) | [docs](https://clerk.com/docs/guides/development/machine-auth/api-keys) | | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | OK (404 unauthenticated; docs-canonical) | none | OK `^pk_(test\|live)_` | url -> dashboard.clerk.com/~/api-keys | [docs](https://clerk.com/docs/guides/development/clerk-environment-variables) | @@ -191,28 +191,28 @@ probe, which is most of them. | `JWT_SECRET` | OK (200) | none | no pattern (locally generated) | none | | | `ENCRYPTION_KEY` | OK (200) | none | no pattern (locally generated) | none | | | `GOOGLE_CLIENT_ID` | OK (200, login wall) | none | OK `\.apps\.googleusercontent\.com$` | none | [docs](https://developers.google.com/identity/protocols/oauth2/web-server) | -| `GOOGLE_CLIENT_SECRET` | OK (200, login wall) | none | UNVERIFIED `^GOCSPX-` — no Google doc states the format; pre-2021 secrets are unprefixed and would be rejected | left as-is; nothing documented to widen to | [docs](https://support.google.com/cloud/answer/15549257) | +| `GOOGLE_CLIENT_SECRET` | OK (200, login wall) | none | UNVERIFIED `^GOCSPX-`, no Google doc states the format; pre-2021 secrets are unprefixed and would be rejected | left as-is; nothing documented to widen to | [docs](https://support.google.com/cloud/answer/15549257) | | `GITHUB_CLIENT_ID` | OK (200, login wall) | none | no pattern | none | | | `GITHUB_CLIENT_SECRET` | OK (200, login wall) | none | no pattern | none | | -| `MAPBOX_ACCESS_TOKEN` | OK (200) | none | LOOSENED `^(pk\|sk)\.` -> `^(pk\|sk\|tk)\.` — tk is a documented temporary token | url -> console.mapbox.com (account.mapbox.com is legacy) | [docs](https://docs.mapbox.com/api/accounts/tokens/) | -| `NEXT_PUBLIC_MAPBOX_TOKEN` | OK (200) | none | OK `^pk\.` — a public token must be pk | url -> console.mapbox.com | [docs](https://docs.mapbox.com/api/accounts/tokens/) | -| `GOOGLE_MAPS_API_KEY` | OK (200, login wall) | none | OK `^AIza` — Maps Platform keys are not affected by the AI Studio auth-key change | none | [docs](https://developers.google.com/maps/api-security-best-practices) | +| `MAPBOX_ACCESS_TOKEN` | OK (200) | none | LOOSENED `^(pk\|sk)\.` -> `^(pk\|sk\|tk)\.`, tk is a documented temporary token | url -> console.mapbox.com (account.mapbox.com is legacy) | [docs](https://docs.mapbox.com/api/accounts/tokens/) | +| `NEXT_PUBLIC_MAPBOX_TOKEN` | OK (200) | none | OK `^pk\.`, a public token must be pk | url -> console.mapbox.com | [docs](https://docs.mapbox.com/api/accounts/tokens/) | +| `GOOGLE_MAPS_API_KEY` | OK (200, login wall) | none | OK `^AIza`, Maps Platform keys are not affected by the AI Studio auth-key change | none | [docs](https://developers.google.com/maps/api-security-best-practices) | | `OPENWEATHER_API_KEY` | OK (200, login wall) | none | no pattern | none | | | `PINECONE_API_KEY` | OK (200) | OK (401) | no pattern | none | | -| `SENTRY_DSN` | OK (200, login wall) | none | LOOSENED `^https://[a-f0-9]+@` -> `^https?://[a-fA-F0-9]+@` — Relay and self-hosted issue http:// DSNs; charset never guaranteed lowercase | url sentry.io (marketing) -> /settings/projects/ | [docs](https://docs.sentry.io/concepts/key-terms/dsn-explainer/) | +| `SENTRY_DSN` | OK (200, login wall) | none | LOOSENED `^https://[a-f0-9]+@` -> `^https?://[a-fA-F0-9]+@`, Relay and self-hosted issue http:// DSNs; charset never guaranteed lowercase | url sentry.io (marketing) -> /settings/projects/ | [docs](https://docs.sentry.io/concepts/key-terms/dsn-explainer/) | | `NEXT_PUBLIC_POSTHOG_KEY` | OK (200, login wall) | none | OK `^phc_` | none | [docs](https://posthog.com/docs/api) | -| `POSTHOG_API_KEY` | OK (200, login wall) | none | LOOSENED `^phx_` -> `^(phx_\|phs_)` — phs_ project secret keys | pattern loosened | [docs](https://posthog.com/docs/api/project-secret-api-keys) | -| `LANGSMITH_API_KEY` | OK (200) | none | OK `^lsv2_` — lsv2_pt_ and lsv2_sk_ | none | [docs](https://docs.smith.langchain.com/administration/concepts) | -| `SLACK_BOT_TOKEN` | OK (200) | none | LOOSENED `^xoxb-` -> `^(xoxe\.)?xoxb-` — rotation-enabled apps get xoxe.xoxb- | CHECK REMOVED: Slack's Web API answers 200 to everything | [docs](https://docs.slack.dev/authentication/using-token-rotation) | +| `POSTHOG_API_KEY` | OK (200, login wall) | none | LOOSENED `^phx_` -> `^(phx_\|phs_)`, phs_ project secret keys | pattern loosened | [docs](https://posthog.com/docs/api/project-secret-api-keys) | +| `LANGSMITH_API_KEY` | OK (200) | none | OK `^lsv2_`, lsv2_pt_ and lsv2_sk_ | none | [docs](https://docs.smith.langchain.com/administration/concepts) | +| `SLACK_BOT_TOKEN` | OK (200) | none | LOOSENED `^xoxb-` -> `^(xoxe\.)?xoxb-`, rotation-enabled apps get xoxe.xoxb- | CHECK REMOVED: Slack's Web API answers 200 to everything | [docs](https://docs.slack.dev/authentication/using-token-rotation) | | `DISCORD_BOT_TOKEN` | OK (200) | OK (401) | no pattern | none | | -| `NOTION_API_KEY` | OK (200) | OK (401) | OK `^(secret_\|ntn_)` — ntn_ since 2024-09-25, secret_ still valid | url -> app.notion.com/developers/connections; steps renamed to connections | [docs](https://developers.notion.com/page/changelog) | -| `LINEAR_API_KEY` | OK (200) | none | LOOSENED `^lin_api_` -> `^lin_(api\|oauth)_` — OAuth tokens use the same header | pattern loosened | [docs](https://linear.app/developers/oauth-2-0-authentication) | -| `AIRTABLE_API_KEY` | OK (403, login or bot wall) | none | REMOVED `^pat` — Airtable documents its tokens as opaque variable-length strings and its OAuth tokens have no documented shape | pattern removed | [docs](https://airtable.com/developers/web/guides/personal-access-tokens) | +| `NOTION_API_KEY` | OK (200) | OK (401) | OK `^(secret_\|ntn_)`, ntn_ since 2024-09-25, secret_ still valid | url -> app.notion.com/developers/connections; steps renamed to connections | [docs](https://developers.notion.com/page/changelog) | +| `LINEAR_API_KEY` | OK (200) | none | LOOSENED `^lin_api_` -> `^lin_(api\|oauth)_`, OAuth tokens use the same header | pattern loosened | [docs](https://linear.app/developers/oauth-2-0-authentication) | +| `AIRTABLE_API_KEY` | OK (403, login or bot wall) | none | REMOVED `^pat`, Airtable documents its tokens as opaque variable-length strings and its OAuth tokens have no documented shape | pattern removed | [docs](https://airtable.com/developers/web/guides/personal-access-tokens) | | `ALGOLIA_APP_ID` | OK (403, login or bot wall) | none | no pattern | none | | | `ALGOLIA_API_KEY` | OK (403, login or bot wall) | none | no pattern | none | | | `CLOUDINARY_URL` | OK (200) | none | OK `^cloudinary://` | none | [docs](https://cloudinary.com/documentation/node_integration) | | `UPLOADTHING_TOKEN` | OK (200, login wall) | none | no pattern | none | | | `RAILWAY_TOKEN` | OK (200) | none | no pattern | url railway.app -> railway.com | [docs](https://docs.railway.com/) | | `FLY_API_TOKEN` | OK (200, login wall) | none | no pattern | none | | -| `NPM_TOKEN` | OK (403, login or bot wall) | none | OK `^npm_` — classic tokens revoked 2025-11-19 | none | [docs](https://docs.npmjs.com/about-access-tokens) | +| `NPM_TOKEN` | OK (403, login or bot wall) | none | OK `^npm_`, classic tokens revoked 2025-11-19 | none | [docs](https://docs.npmjs.com/about-access-tokens) | From a83b25e64f67bd3384d91d9e9ece9d950f37174d Mon Sep 17 00:00:00 2001 From: Krish Garg <181051779+kgarg2468@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:54:22 +0000 Subject: [PATCH 2/2] readme: only keys with an unattended check are re-checked before delivery Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 235be3b8..eef9b8e7 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ When code needs a key, the agent runs `tokenstash need OPENAI_API_KEY`. One of t - **Approval is per folder, and you choose how wide it goes.** The first time a folder asks for keys you already have, the card offers three answers. **Allow these** covers only the listed keys. **Allow these + any non-sensitive key here** also covers keys tokenstash knows and rates as non-sensitive, now and later. **Deny** says no, and tokenstash remembers that for 24 hours by default. - **Sensitive keys always ask.** Live Stripe secret keys, AWS credentials, deploy and package-registry tokens, and any key tokenstash doesn't recognise get their own approval in each folder. The broad answer never covers them. - **Local secrets are generated, not asked for.** tokenstash creates `AUTH_SECRET`, `JWT_SECRET`, `SESSION_SECRET` and similar values itself, one per folder. -- **Keys are re-checked with the provider.** Before it hands over a key whose provider offers a free check, tokenstash asks the provider whether the key still works, at most once a day by default. A rejected key becomes a Replace card, so you paste a new one before your code gets a 401, the error a provider returns for a bad key. If the provider can't be reached, the key goes out unchecked. +- **Keys are re-checked with the provider.** When a provider's check is safe to run unattended, tokenstash asks the provider whether the key still works before it hands the key over, at most once a day by default. A rejected key becomes a Replace card, so you paste a new one before your code gets a 401, the error a provider returns for a bad key. A key whose check is not safe to run unattended is checked only when you paste it. If the provider can't be reached, the key goes out unchecked. ## Where your key goes