tokenstash holds API keys on your machine, so a defect here has real consequences. Reports are welcome and will be answered.
Use GitHub's private vulnerability reporting: Report a vulnerability (Security → Advisories on the repository). That channel is private until a fix ships.
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.
Expect an acknowledgement within a few days. There is no bounty; this is a personal project.
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.
- Credential delivery does not echo the credential.
tokenstash needand thesecrets_requestMCP 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, andexportwrites every value, encrypted, to a file you name. - 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. - Generated secrets are per directory and need no human.
JWT_SECRET,AUTH_SECRETand the like are minted locally, one per directory (identity<dir>-<hash>), 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. - 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 intokenstash 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). - 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
/verifywithHMAC(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 retiredinbox_links = "full"is covered below). - 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.
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 runtokenstashitself. tokenstash defends the agent boundary, meaning what the tools and links it hands a model can do, and not the user boundary.TOKENSTASH_HOMEandTOKENSTASH_STASHare 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
--identityto 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.shdrives 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, 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 makeneed,askand 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 runningtokenstash openin 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, 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,bindandinit --mode,--mcp,--no-mcpand--undo, run by an agent, file a card that changes nothing until the person confirms it with the full session;rotatefrom an agent files a Replace card and does not mark the key stale.need --forcefrom 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,auditandcheckshow 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, which are
answer --allowand confirming an action card,open,tasks --all,workspaces,exportandimport, 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, andTOKENSTASH_AGENTis not set at all (setting it tounknownused 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;openthen prints the session andexportwrites 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
.gitignorewhen it can, and refuses the write if git cannot be consulted. Nothing stopsgit add -fafterwards, 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 withremote_loginis 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), sotokenstash remote offtakes 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, andtokenstash remote tailscalefails 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. Atailscale whoisthat fails or does not answer within three seconds counts as no login, and the next request asks again. Release builds runtailscaleonly from where Tailscale installs it (on Linux/usr/bin,/usr/sbin,/usr/local/binor the NixOS system profile; on macOS the app,/usr/local/binor/opt/homebrew/bin), with a fixedPATHand none of the caller's environment exceptHOME, so neitherPATHnorTOKENSTASH_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. - The Linux kernel keyring (
keyutils) does not survive a reboot. It is chosen automatically when no Secret Service is running;tokenstash liststill shows the names afterwards, and the nextneedasks again. Install a Secret Service (gnome-keyring, KeePassXC) for persistence. Keys there are linked into your user keyring and your persistent keyring, and any process running as you can read them, from any login session; that is what lets a key pasted from one session reach an agent in another. - The
insecure-filestash backend stores values in plaintext at 0600. It exists for CI and tests, warns on every use, and is never selected automatically.