Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3d8884e
keyutils: relink keys on a read under the writers' lock
krishhgg Oct 5, 2026
0cef292
need: count an import as a finished store of a generated key
krishhgg Oct 5, 2026
db8d0c4
run: mask the lines of a key held with escaped line breaks
krishhgg Oct 5, 2026
c7fe763
doctor: note a kernel keyring copy held only by another session
krishhgg Oct 5, 2026
9566212
doctor: check unindexed keys and say when /proc/keys cannot be read
krishhgg Oct 5, 2026
007ff3c
need: scope an agent's --force to each declined key and identity
krishhgg Oct 5, 2026
30c32c6
need: say a second ask on approval cards; return only the retried ide…
krishhgg Oct 5, 2026
55ac86b
inbox: decide an approval card's Deny on the card the page showed
krishhgg Oct 5, 2026
4f36164
actions: renew a running card's claim and forget only the value it wa…
krishhgg Oct 5, 2026
c25f96b
audit: start an agent's view after the last row before its workspace
krishhgg Oct 5, 2026
dc6128e
init: stop `claude mcp add` and `remove` after 60 seconds
krishhgg Oct 5, 2026
ac88428
actions: keep a value with no index row on a forget retry; free a car…
krishhgg Oct 5, 2026
7834939
remote: trust only an installed tailscale, and prove every tailnet link
krishhgg Oct 5, 2026
b8cd808
remote: prove the tailnet address once for each batch of links
krishhgg Oct 5, 2026
cdd5568
init: keep every section and unknown entries on undo, refuse an unsaf…
krishhgg Oct 5, 2026
87660f9
init: undo restores saved sections without a shipped one; a failed ca…
krishhgg Oct 5, 2026
619b3cc
init: a failed card puts back only the MCP entries it changed
krishhgg Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ These are design boundaries, documented so you can judge them rather than discov
- **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.
- **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 after that check 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 does not answer within three seconds counts as no login. Turning it on does not need a card, since it is how the person reaches the inbox at all; an agent can run it.
- **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.
- **The Linux kernel keyring (`keyutils`) does not survive a reboot.** It is chosen automatically when no Secret Service is running; `tokenstash list` still shows the names afterwards, and the next `need` asks 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-file` stash backend stores values in plaintext** at 0600. It exists for CI and tests, warns on every use, and is never selected automatically.
2 changes: 1 addition & 1 deletion crates/cli/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Some commands act for the user. Run them only when the user asks, and pass `--wh
| Replace a key that still works | `tokenstash rotate NAME` |
| Delete a stored key | `tokenstash forget NAME` |
| Always use another identity for a key in this project | `tokenstash bind NAME --identity work` |
| Ask again for a key they declined | `tokenstash need NAME --force` (once per key until the "no" expires) |
| Ask again for a key they declined | `tokenstash need NAME --force` (once per key and identity until the "no" expires) |
| Stop agents loading this skill on their own, or let them again | `tokenstash init --mode explicit`, `tokenstash init --mode auto` |
| Register or remove the MCP server | `tokenstash init --mcp`, `tokenstash init --no-mcp` |
| Take tokenstash out of their agents | `tokenstash init --undo` |
Expand Down
8 changes: 5 additions & 3 deletions crates/cli/skill/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Writes each key to the project's env file, or files a card for it. The project i
| `--blocking` | Wait for the user instead of returning at once |
| `--timeout SECONDS` | With `--blocking`, how long to wait (default 600) |
| `--agent NAME` | Your name on the card and in the audit log (detected otherwise) |
| `--force` | Ask again after the user declined. Once per key and project until the "no" expires; the card says it is a second ask |
| `--force` | Ask again after the user declined. Once per key, identity and project until the "no" expires; the card says it is a second ask, and the same command run again while that card waits returns it |
| `--json` | One JSON object instead of lines |

Generated secrets (`AUTH_SECRET`, `JWT_SECRET`, `SESSION_SECRET`, `NEXTAUTH_SECRET`, `ENCRYPTION_KEY`) are never asked for: tokenstash generates one per project, or keeps the value the env file already holds.
Expand Down Expand Up @@ -95,11 +95,13 @@ Runs COMMAND with the env file loaded into its environment. Its output reaches y

### `tokenstash doctor`

Checks the setup, one line each: config file, stash backend (and how long keys last in it), database, provider registry, inbox, agent mode, the skill and any MCP registration per agent, this directory (refused, paired, or not yet), and the binary path. Exit 0 when nothing is wrong, 1 otherwise. Safe to run at any time.
Checks the setup, one line each: config file, stash backend (and how long keys last in it), older copies (Linux kernel keyring only), database, provider registry, inbox, agent mode, the skill and any MCP registration per agent, this directory (refused, paired, or not yet), and the binary path. Exit 0 when nothing is wrong, 1 otherwise. Safe to run at any time.

The older copies line looks at every key this home's index lists and every key the kernel keyring holds for this home, for copies a tokenstash older than 0.4 keeps. It fails when a key's user keyring and persistent keyring hold different values, because a read can then return the old key. It passes with a note when only another login session holds a copy, which matters only while an older tokenstash runs in that session. When `/proc/keys` cannot be read, it says so and looks only at the keys the index lists.

### `tokenstash remote [tailscale [--login LOGIN] | off]`

Whether the inbox can be reached from the user's other computers. With no argument, prints the setting. `tailscale`: the inbox also listens on this machine's Tailscale address, every link and notification uses that address, and a request from another device signed in to the owner's Tailscale account (`--login`, or the account this machine is signed in with) counts as the user, so it can approve. Nothing else on the tailnet gets an answer. A running inbox picks the change up within a second. Links name the Tailscale address only once tokenstash has proved its own inbox answers there; until then, or if another process holds that address and port (`remote tailscale` then fails and says so), they stay on 127.0.0.1. Links printed before still point at 127.0.0.1. On a Tailscale link the user decides on the card itself from any of their devices, with no desktop notification needed. `off`: back to 127.0.0.1 only. You may run it when the user is on another computer; it needs `tailscale` connected here. `--login` is for a tagged node, which has no owner; on a machine signed in to Tailscale, naming another login is the user's to do at a terminal.
Whether the inbox can be reached from the user's other computers. With no argument, prints the setting. `tailscale`: the inbox also listens on this machine's Tailscale address, every link and notification uses that address, and a request from another device signed in to the owner's Tailscale account (`--login`, or the account this machine is signed in with) counts as the user, so it can approve. Nothing else on the tailnet gets an answer. A running inbox picks the change up within a second. Links name the Tailscale address only when tokenstash proves, each time it prints them, that its own inbox answers there; until then, or if another process holds that address and port (`remote tailscale` then fails and says so), they stay on 127.0.0.1. Links printed before still point at 127.0.0.1. On a Tailscale link the user decides on the card itself from any of their devices, with no desktop notification needed. `off`: back to 127.0.0.1 only. You may run it when the user is on another computer; it needs Tailscale installed where its installer puts it (tokenstash does not look on PATH) and connected here. `--login` is for a tagged node, which has no owner; on a machine signed in to Tailscale, naming another login is the user's to do at a terminal.

### `tokenstash registry`

Expand Down
2 changes: 1 addition & 1 deletion crates/cli/skill/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Unless remote access is on, the inbox listens on `127.0.0.1` of the machine toke

Follow "When a provider rejects a key" in `SKILL.md`: `tokenstash report-bad NAME --status 401`, then `tokenstash need NAME`. If the second `need` writes the key again, the provider accepted it on re-check: compare your request with the provider's documentation (auth header name and format, base URL, project or organization header).

If the user pasted a new key and the old, rejected one keeps coming back, run `tokenstash doctor` and look at the stash backend line. With `keyutils` (the Linux kernel keyring, used when no Secret Service runs), versions before 0.4 kept a separate copy per login session, so a key pasted from one session could be shadowed in another; `doctor`'s last line shows which binary runs. Tell the user what you found.
If the user pasted a new key and the old, rejected one keeps coming back, run `tokenstash doctor` and look at the stash backend line. With `keyutils` (the Linux kernel keyring, used when no Secret Service runs), versions before 0.4 kept a separate copy per login session, so a key pasted from one session could be shadowed in another; `doctor`'s last line shows which binary runs. An older tokenstash still running in another login session (an inbox or `tokenstash mcp` started before the upgrade) keeps doing this, and this version cannot fully prevent it. No other session can write the copy that process holds. When the process links its copy into the shared keyring, the kernel does not record whether it read an old value or wrote a new one, so this version keeps the value it had, and a key pasted through the old process can be passed over. The `older copies` line of `doctor` then names the keys. It fails when a key's user keyring and persistent keyring hold different values, which is when a read can return the old key. When another login session only holds a copy, the line passes, since the copy matters only while an older tokenstash runs in that session. Tell the user what you found. They stop the older process, and if the old key is still the one in use, replace it with `tokenstash rotate NAME`.

## `need` exits 1

Expand Down
Loading
Loading