Security

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:

FormWhere it livesWho can read it
Real valueOS 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.
PlaceholderThe chat transcript, files, and the SQLite inventoryAnyone who can read the chat — but it is {{stm:openai:default}}, an address, not a secret.
CiphertextAn encrypted vault snapshot, or a self-hosted Teams serverOnly 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…" ...
Bash only — never a file

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#

HookWhat it does
PreToolUseThe 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.
UserPromptSubmitBlocks 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.
PostToolUseFlags 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.
SessionStartInjects 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:

PartyTrusted with the key?What separates it
The AI model / chat transcriptNoSees only placeholders; substitution happens after the model has produced the command.
The local dashboardEntry onlyBound to 127.0.0.1, per-run auth token, Host/Origin allowlist (DNS-rebinding defense). No remote host can reach it.
subscribetome's SQLite inventoryNoStores a keychain reference + metadata; the value stays in the OS keychain.
Provider APIsYes (they issued the key)The broker attaches auth only on the outbound call to the target's own origin.
A self-hosted Teams serverNoZero-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)#

Read this section

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.
Zero-knowledge server

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.

Reporting a vulnerability

Found something? See SECURITY.md for the private disclosure process.