Skip to content

feat(keys): keep agents' keys out of the chat - #267

Merged
httpsphl merged 5 commits into
mainfrom
feat/agent-keys
Oct 26, 2026
Merged

httpsphl merged 5 commits into
mainfrom
feat/agent-keys

Conversation

@httpsphl

@httpsphl httpsphl commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Agents' keys (spec 32): an agent gets a token or password for another service as an environment variable, by name, without the value going through the chat.

What changes

  • K1, keep and use. keys.list/set/delete. Values live in secrets\keys\<bot_id>.json (user-only ACL), never in the database, logs, backups or the cloud; the database keeps names, descriptions and when they changed. The agent restarts with --resume and gets each key in its environment, in all three engines, plus .claude/rules/keys.md (or the end of AGENTS.md) with the names and the rule to never print a value. Whatever the daemon stores (chat items, approvals, messages) has saved values swapped for [chave NAME]. Reserved names (PATH, ANTHROPIC_*, BOTLOFT_*...) are refused.
  • K2, the request. New MCP tool ask_key {name, why}. It returns at once and opens a key_request chat item. keys.set with requestId saves the key and closes the request; keys.dismiss says "not now". Either way the agent gets a message from Botloft. At most 5 open requests per agent; the phone never gets them.
  • K3, the app. The request card with a hidden field and an eye, which shrinks to "You saved the key NAME". A Keys part in the agent's details: add, replace, delete with confirmation, and "Value missing" after a restore. When the owner pastes a saved key in the chat, the composer says the agent got its name instead. Texts are in English, Portuguese and Spanish.
  • K4, keys already pasted. keys.scan / keys.scrub and a new Settings page, Security. It finds known key prefixes and JWTs in every text Botloft keeps and shows only their start and end. "Keep as a key" saves the value for an agent and swaps it for [chave NAME]; "Delete from Botloft" swaps it for [chave removida]. The chat search follows through its triggers.
  • Supervisor fix, found with the real Claude Code. While a restart for new settings waits for the running turn, write_message now refuses, so the courier defers and the next message goes to the new process. Before this, a key saved mid-turn was announced to the old process, which ran without the variable.

The spec is updated in the same commits: section 32, the tool table in 10, and a row in 19.

Tests

  • cargo fmt, clippy -D warnings, cargo test --workspace and pnpm check pass locally.
  • tests/agent_keys.rs, tests/key_requests.rs (including a key saved mid-turn), tests/key_scan.rs, key_scan.rs in core and store, features/keys/*.test.tsx.
  • Test tokens shaped like Slack's are built in two parts, so push protection does not take them for real ones.

Manual check with the real Claude Code

Done on 2026-10-26 with Claude Code 2.1.309, Haiku and Manual mode, on a separate test daemon (spec 19):

  1. Ask an agent to request DEMO_TOKEN with ask_key and end its turn. It loads the tool through ToolSearch and a card shows in its chat.
  2. Save a test value from the card while the turn is still running. The agent restarts after the turn, gets the note and $env:DEMO_TOKEN.Length is right.
  3. Ask it to Write-Output $env:DEMO_TOKEN. It refuses by its rule; once allowed, the output shows [chave DEMO_TOKEN] in the chat.
  4. Look for the value in the daemon log, database and workspace: it is not there. It is only in secrets\keys and in Claude Code's own session history, as spec 32.6 says.

Still to come: the request in the island (spec 31.5), together with the island window.

First slice (K1) of spec 32. The owner saves a key for one agent with
`keys.set` (name, value, description); `keys.list` gives the names back,
never a value, and `keys.delete` removes one. The value lives in
secrets\keys\<bot_id>.json, readable only by the current user; the
database keeps the name and description. The agent restarts, resuming
its conversation, with the key as a variable of its process, and its
rules (.claude/rules/keys.md, or AGENTS.md for the other engines) list the
names with the rule never to print a value. Names the system, Botloft or
an engine use are refused.

Every text the daemon keeps about the agent (replies, tool calls and
results, approval requests, messages it gets or sends, notices) has the
agent's key values replaced by `[chave NAME]` before it is stored.
Deleting the agent deletes its keys.
The ask_key tool opens a key_request card in the agent's chat and returns
at once. The owner answers with keys.set and the request id, which saves
the key, restarts the agent and tells it the key is in its environment,
or with keys.dismiss, which tells it the key did not come. The request
is the chat item itself, so there is no key_requests table (spec 32.9
updated). At most 5 open requests per agent; the phone never gets them.
The key_request card shows what the agent needs and a hidden field with
an eye, then shrinks to "You saved the key NAME" or "You did not give
the key NAME". The agent's details get a Keys part: each key with what it
is for, when it changed and "Value missing" after a restore, with
replace, delete and add. After the owner pastes a saved key in the chat,
the composer says the agent got its name instead. The fake daemon swaps
saved values for names as the real one does, and the preview seeds a
request and two keys. Texts in English, Portuguese and Spanish.
keys.scan looks through every text Botloft keeps (messages, chat items,
approval requests, questions, task results) for known key prefixes and
JWTs, and returns each value once with only its start and end, the
agents it showed up for and how many places. keys.scrub keeps a hit as
an agent's key, which restarts it with the variable, and swaps the value
for [chave NAME] everywhere, or swaps it for [chave removida]; the chat
search follows through its triggers. Settings gets a Security page with
the search, keep and delete, and the reminder to revoke the key at its
service.
…aits

Found with the real Claude Code: an agent asked for a key and the owner
saved it while its turn was still running. The restart waited for the
turn, but the note saying the key was in its environment went to the old
process first, which then ran without the variable. While a restart for
new settings is pending, write_message now refuses, so the courier
defers and the message reaches the new process. The spec records the
check in section 19 and the rule in 32.4.
@httpsphl
httpsphl enabled auto-merge October 26, 2026 15:14
@httpsphl
httpsphl merged commit f18db65 into main Oct 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant