Security architecture & threat model.
subscribetome has one core invariant: a real API key value lives only in the OS keychain — the model, the chat transcript, and your logs never hold it. This page explains how that holds, what it protects you against, and — with equal weight — what it does not.
Design principles#
- The secret is not ours to hold. A key value is written straight to the OS keychain out-of-band, and read only at the moment it is needed. subscribetome's own database never stores a value.
- The conversation is the thing being protected. An API key pasted into a chat is logged in that transcript forever — in every backup, screen-share, and shared session. The design keeps the value out of the conversation by construction, not by redaction after the fact.
- No backend. There is no server, no cloud, no telemetry, no sign-up. There is nothing to breach on our side because nothing of yours is on our side.
- Fail safe, never fail open. On any internal error a hook exits without substituting; the worst case is a command that runs with an un-substituted placeholder and simply fails.
- Be honest about the edges. The limitations below are documented as plainly as the guarantees. A security tool that hides its edges is worse than one that names them.
What is protected, and where#
The asset is the API key value. It can exist in exactly one of three forms, and the design controls all three:
| Form | Where it lives | Who can read it |
|---|---|---|
| Real value | OS keychain (macOS Keychain; Linux Secret Service / pass / opt-in encrypted file; Windows Credential Manager) | Your OS, gated by the local user account. Read only inside a hook, at the moment of use. |
| Placeholder | The chat transcript, files, and the SQLite inventory | Anyone who can read the chat — but it is {{stm:openai:default}}, an address, not a secret. |
| Ciphertext | An encrypted vault snapshot, or a self-hosted Teams server | Only a holder of the vault passphrase / team key. The server stores ciphertext it cannot decrypt. |
The SQLite inventory (~/.subscribetome/) holds only a keychain_ref UUID plus metadata (tool, label, plan, cost) — never a key value.
Placeholder & PreToolUse substitution#
In chat, a key is referenced only by a placeholder: {{stm:<tool>:<label>}}.
<tool> and <label> are lowercase [a-z0-9-], 1–64 characters each; the
(tool, label) pair is the global address of a key. Substitution is an exact match — a malformed
placeholder is never substituted; it is blocked with a did-you-mean suggestion.
The load-bearing mechanism is the PreToolUse hook. When Claude Code is about to run a Bash command, the hook
rewrites that one command via updatedInput, replacing the placeholder with the real key the instant before
execution. The model proposes the placeholder; the shell receives the value; the transcript keeps the placeholder.
model writes: curl -H "Authorization: Bearer {{stm:openai:default}}" ... │ PreToolUse hook │ resolves the key from the keychain, rewrites this one command ▼ shell runs: curl -H "Authorization: Bearer sk-…real…" ...
The hook substitutes a key only into Bash commands, never into a file. A placeholder written to a file
stays the harmless token; what the hook blocks in a Write/Edit is a raw key about
to be persisted to disk.
The four Claude Code hooks#
| Hook | What it does |
|---|---|
| PreToolUse | The load-bearing one. Substitutes the placeholder with the real key into a Bash command the instant before it runs, via updatedInput. The model never sees the value. |
| UserPromptSubmit | Blocks a prompt that contains a raw key shape or any managed secret matched by exact value — including a plain password with no key shape. Secrets must never enter the chat. |
| PostToolUse | Flags command output that surfaced a managed key (e.g. a command that echoed its own input) so you can rotate. It detects a leak after the fact; it cannot un-leak it. |
| SessionStart | Injects usage guidance so every new session knows how to use stm-managed keys with zero per-project setup. |
The command-policy engine#
Before a key is ever resolved from the keychain, PreToolUse evaluates a set of allow / deny / warn rules over the
tuple (key, command, agent, project). Rules are glob-matched; you can test a command against the active rules
before you save one. Because policy runs before keychain resolution, a denied command never causes a key to
be read at all. Every decision is written to the audit log as one of
substitute · policy.deny · policy.warn · unresolved ·
malformed · broker — and the audit log never holds a real key value.
Trust boundaries#
Naming the untrusted parties explicitly, and what separates each:
| Party | Trusted with the key? | What separates it |
|---|---|---|
| The AI model / chat transcript | No | Sees only placeholders; substitution happens after the model has produced the command. |
| The local dashboard | Entry only | Bound to 127.0.0.1, per-run auth token, Host/Origin allowlist (DNS-rebinding defense). No remote host can reach it. |
| subscribetome's SQLite inventory | No | Stores a keychain reference + metadata; the value stays in the OS keychain. |
| Provider APIs | Yes (they issued the key) | The broker attaches auth only on the outbound call to the target's own origin. |
| A self-hosted Teams server | No | Zero-knowledge: it stores ciphertext it cannot decrypt, and team tokens only as SHA-256 hashes. |
Fail-safe behaviour#
Hooks fail closed on the secret. If a keychain read fails, a passphrase is missing on a non-TTY host, or any internal
error occurs, the hook exits without substituting — so a failure can never leak a key. At worst a command runs
with the placeholder intact and fails harmlessly. On the encrypted-file backend, when neither a cached passphrase nor
STM_FILE_PASSPHRASE is available and stdin is not a TTY, the resolver returns null rather than prompting
or leaking.
Honest limitations (out of scope)#
These are real, and inherent to injecting a secret into a shell command. subscribetome keeps a key out of the conversation; it does not claim to keep it out of the local process table.
- The substituted key is a real argv element. At the moment of execution the key is an argument of the shell
command — briefly visible to a local
ps, and to any other process running as your user. - A command that echoes its own arguments can still print it.
set -x, verbose modes, or an error message that embeds the command can surface the substituted key in that command's output. PostToolUse flags this after the fact and tells you to rotate — it cannot prevent the leak. - Output redaction is impossible. A hook can only block a tool result that contains a key, not silently scrub it. Flagging is reliable for keys subscribetome manages and best-effort for others.
- Endpoint trust is assumed. A compromised machine, malware inspecting process memory, a maliciously replaced binary, or a hostile local user are out of scope — the same boundary every local secret manager has.
- Platform scope. The per-command rewrite guarantee is strongest on macOS + Claude Code. Linux and Windows keystores work but the runtime surface is experimental; Codex has weaker or MCP-mediated modes. See DOCS.md.
How the broker closes the argv gap#
The argv limitation above is inherent to putting a secret into a command. For the common case — HTTP APIs — the credential broker removes it entirely. Instead of substituting the key into the command, the agent points its request at a local daemon, and the daemon injects the real auth on the outbound call to the provider:
curl http://127.0.0.1:<port>/proxy/openai/default/v1/chat/completions \ -H "x-stm-token: <broker-token>"
The key never enters the command's argv, environment, or output. The command carries only a loopback-only capability token that exposes no secret. The broker enforces an SSRF guard, refuses to auto-follow redirects, scrubs the key from the response, and caps the response size. See the broker guide for the full set of invariants.
Teams cryptography#
STM Teams extends the keychain-only principle across a team without the server ever seeing a plaintext secret. Three primitives:
- Vault encryption. The shared credential vault is AES-256-GCM with a key derived by PBKDF2-SHA512 from the team key. Credentials are encrypted on a member's machine; the server receives only the ciphertext blob.
- Member enrollment (sealed box). Distributing the team key uses an X25519 sealed box: ephemeral-static ECDH → HKDF-SHA256 → AES-256-GCM. An existing member seals the team key to a joiner's public key; only the joiner's private key can open it. No shared passphrase is ever transmitted.
- Usage attribution (signatures). Audit reports are signed with each member's Ed25519 key. The server verifies the signature and attributes the event to the cryptographically-verified member id — a client cannot forge who it is. A member id is
sha256(sealPub‖signPub)truncated to 128 bits, binding both public keys so neither can be substituted.
A fully-compromised Teams server (stolen database, malicious host) yields no key: it holds only ciphertext it cannot decrypt and token hashes it cannot reverse. Usage metadata (tool, label, counts) is plaintext to the server you host — see the Teams limits for what that means.
Found something? See SECURITY.md for the private disclosure process.