Repository navigation
Security hardening: OS credential store, human consent for writes, encrypted cache - #28
Closed
AdelsJarlen wants to merge 11 commits into
Closed
AdelsJarlen wants to merge 11 commits into
AdelsJarlen wants to merge 11 commits into
Conversation
keyring backs the OS credential store that will hold the YNAB token and the cache encryption key; cryptography provides AES-256-GCM for encrypting cached budget data at rest. Both carry upper bounds, matching the convention the dependency block already documents. cryptography was already present transitively via mcp[cli]; it is declared explicitly now that it is imported directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
The token never reached the model — this server is a local subprocess and the
LLM only sees tool return values. The leak was the config file: `claude mcp
add-json ... "env":{"YNAB_API_KEY":"..."}` writes the token in plaintext into
~/.claude.json or claude_desktop_config.json, which any agent with a file-read
tool can open. This moves it into the OS credential store instead.
src/security/credentials.py wraps keyring (Windows Credential Manager, macOS
Keychain, Secret Service) under service name "mcp-ynab". Settings now resolves
the token in order: credential store, then YNAB_API_KEY with a deprecation
warning on stderr, else a MissingCredentialsError pointing at `auth login`.
The store wins over the environment so a fresh login takes effect even when a
stale key is still sitting in an MCP client config. YNAB_ALLOW_ENV_KEY=0
refuses the environment variable outright.
Settings.ynab_api_key is a SecretStr; the single .get_secret_value() call is
in YNABClient.__init__ where the Authorization header is built, and the token
is not stored on the client afterwards.
These stores scope secrets to the logged-in user, not to a program: code
running as the same user can still ask for the same secret. SECURITY.md (added
later in this branch) says so plainly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
YNAB error bodies and httpx exception reprs can echo request headers, and a tool's error return value is a string the model reads. handle_errors now renders every error payload through scrub(), which replaces any registered live secret with ***REDACTED***. Scrubbing the rendered JSON rather than each field covers nested values and any field added here later. The token registers itself in YNABClient.__init__, the one place it is unwrapped. This is a backstop behind SecretStr, not a substitute for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
The `mcp-ynab` entry point now resolves to src.cli:main. A bare invocation still starts the stdio server, so existing MCP client configs keep working. - auth login: reads the token with getpass (never argv, never echoed, never in shell history), validates it against GET /user, and only then stores it. An invalid token is refused rather than saved, so a typo fails here instead of surfacing as a confusing auth error later. - auth status: token source and fingerprint, never the token. Warns when a stale YNAB_API_KEY is being shadowed by the credential store. - auth logout: deletes the stored token. - cache purge: deletes the cache DB, its WAL sidecars, and the cache key. Server modules are imported lazily inside serve(), since importing them constructs Settings, which refuses to build without a token — `auth login` has to run before a token exists. Subcommand output goes to stdout because it is read by a human in a terminal; the server path still prints nothing there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
The cache holds payees, memos, amounts and category names — a fairly complete picture of the user's finances — in a SQLite file under the user profile. The payload columns are now AES-256-GCM, with a 256-bit key generated on first use and kept in the OS credential store beside the token. EncryptedJSON is a SQLAlchemy TypeDecorator, so src/cache/service.py is untouched: it still hands dicts to the ORM and gets them back. The envelope is nonce(12) || ciphertext || tag, with AAD "mcp-ynab:<table>.<column>". That AAD binds a ciphertext to its table and column but not to its row, so someone with write access to the DB could swap ciphertexts between rows of one table; they already have write access, and SECURITY.md documents the limit rather than hiding it. plan_id, entity_type, entity_id, cache_key, is_deleted, cached_at, ttl_seconds, knowledge and last_synced_at stay plaintext because lookups and delta sync need them. That leaks metadata — entity counts, sync timing — but no amounts, payees, memos or category names. A storage-layer framing byte in front of the blob distinguishes an encrypted payload from a plaintext one, so YNAB_CACHE_ENCRYPTION=0 (debugging only) can be toggled without the stored rows decoding as garbage. Migration extends the existing drop-and-rebuild in _migrate_cache_tables: a non-BLOB `data` column on cached_entity or response_cache drops that table. Dropping cached_entity also drops server_knowledge — otherwise delta sync would still believe it was current, fetch only changes since that knowledge, and the entities dropped here would never come back. A missing credential store fails closed with a message naming YNAB_CACHE_ENCRYPTION=0, rather than quietly writing plaintext while the user believes the cache is encrypted. The DB file is chmod 0600 on POSIX; on Windows it inherits the user-profile ACL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
All 16 mutating tools now ask a person before they change anything, and show them a real before/after diff rather than a summary. The security property: the "before" comes from the YNAB API, not from anything the model said. Two things make that hold. A tool parameter annotated `Context` is injected by the SDK and does not appear in the tool's input schema (verified against the installed mcp 2.0.0), so the model cannot supply, forge or suppress it. Consent therefore cannot be satisfied by anything the model emits. Pre-images are read through `_shared.client`, which bypasses the cache. That is not optional: a stale cached row would show the human a false "before". Each call site then fingerprints the resolved payload before eliciting and asserts the fingerprint after, so nothing can be re-resolved between the prompt and the write — a guarantee a test can now assert rather than a convention. Declining, cancelling, an elicit that raises, or a client that never advertised elicitation all block the write. There is no fallback that applies the change unconfirmed; ConsentUnavailable is surfaced to the model as an actionable error naming the fixes. Bulk operations itemise every row with its own diff and never summarise as a count — "12 transactions" is exactly the prompt a human waves through without having seen it. If that makes the prompt long, the prompt is long. import_transactions has no meaningful diff, so it names the linked accounts that may be touched and states plainly that the result is not knowable in advance. Tool names and visible parameter schemas are unchanged: 47 tools, none leaking ctx. The existing tests keep their assertions on exactly which payload reaches the cache service; they only gained the fixtures the gate now requires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
YNAB_READ_ONLY=1 stops the 16 mutating tools being registered at all, so they never reach the model's tool list: there is nothing to call and nothing to talk the server out of, rather than an offered capability that then refuses. Off by default — writes stay enabled, per the brief. With it on the server offers 31 read tools instead of 47. consent.confirm() also refuses in read-only mode. That guard should be unreachable given the registration gate; it costs nothing and covers a tool that somehow is reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
SECURITY.md states the threat model in full, including the parts that are not flattering: the credential store scopes secrets to the logged-in user, not to a program, so an agent with shell access as that user can still retrieve the token. Closing that needs a separate OS account, a hardware token, or a broker process, none of which a pip-installable server can deliver. Also documents what the cache encrypts, the metadata the plaintext lookup columns still leak, the AAD row-swap limitation, and how to rotate a token and purge the cache. README replaces the config examples that embedded YNAB_API_KEY in JSON with the `mcp-ynab auth login` flow. This is the change that matters most in practice: the README is where people copy the insecure pattern from. Adds the environment variable table, a worked example of a consent prompt, and a plain warning that writes refuse outright on a client without elicitation support. CLAUDE.md notes the new src/security/ package and src/cli.py, and records the conventions for a mutating tool: a Context parameter, a pre-image from _shared.client rather than the cache, and registration through mutating_tool(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
Two wording fixes found by reading the rendered prompts rather than the code: - The closing line said "Confirm to apply exactly this. Nothing else will be sent", which reads oddly on a delete. Now "Confirm to proceed with exactly this, and nothing else" — same guarantee, correct for every operation. - An em dash in the update_category_for_month subject and in the cache encryption error. Every other prompt string is ASCII; these two were the exceptions, and prompt text crosses transports and consoles whose encoding we do not control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
Three bugs, all hit while setting the server up for the first time on Windows. A token containing a control character built a malformed Authorization header, and YNAB answered with a bodyless HTTP 400. This happens in practice: on the Windows console Ctrl+V at a hidden getpass prompt is delivered as the literal 0x16 character instead of pasting, so the "token" was one control character. _why_not_a_token() now catches control characters, whitespace and obviously short input before the network call, and names Ctrl+V as the likely cause. Verified against the live API: a well-formed but wrong token gives 401, a control character gives 400. _validate_token reported any non-YNABError as "could not reach YNAB", which was wrong for that 400 — YNAB was reached and answered. HTTPStatusError and RequestError are now distinguished. The interactive guard checked only sys.stdin.isatty(). Under Git Bash/MSYS on Windows that returns True even when stdin is redirected, and getpass resolves to win_getpass, which reads the console directly and ignores the redirect — so the guard never fired and the process blocked forever waiting for a human who was not there. It now requires stdout to be a terminal too, turning an indefinite hang into an actionable message. Adds `auth login --stdin` for terminals that will not paste into a hidden prompt, and for headless setups. Piping from a file keeps the token out of argv and out of shell history, which is the property that matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
Windows PowerShell 5.1 prefixes anything it pipes to a native executable with a UTF-8 BOM. A BOM is not whitespace, so .strip() left it in place, and the control-character check added in the previous commit then rejected it — which broke the most natural way to feed a token in on the platform the --stdin flag was added for. Stdin is now decoded as utf-8-sig, which consumes the BOM, and any stray BOM elsewhere in the input is dropped too; clipboard and editor round-trips occasionally introduce one. Regression tests cover all four input shapes seen for real: the Ctrl+V control character, whitespace, a truncated paste, and BOM-prefixed stdin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017muqN7n9KiU8XeFbBhJxrM
Author
|
Opened against the wrong repository by mistake — this belongs on the fork, not upstream. Closing immediately; apologies for the noise. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security hardening, not remediation. Upstream was audited and found clean — no
malware, no exfiltration, a single pinned endpoint. This closes a mundane but
real exposure and adds a consent gate on writes.
What changed
The token moves out of plaintext config. The model never had access to it —
this server is a local subprocess and the LLM only sees tool return values. The
actual leak was
claude mcp add-json ... "env":{"YNAB_API_KEY":"..."}, whichwrites the token in plaintext into
~/.claude.jsonorclaude_desktop_config.json. Any agent with a file-read tool, including ClaudeCode itself, can open that file. It now lives in the OS credential store via
keyring, withmcp-ynab auth loginto put it there.Every write requires human confirmation, with a real diff. All 16 mutating
tools now elicit approval before sending anything to YNAB. The security property
is that the "before" shown to the human comes from the YNAB API, not from
anything the model said. Two things hold that up:
Contextis injected by the SDK and does notappear in the tool's input schema, so the model cannot supply, forge or
suppress it. Consent cannot be satisfied by anything the model emits.
_shared.client, bypassing the cache — a stalerow would show a false "before". Each call site fingerprints the resolved
payload before eliciting and asserts it afterwards, so nothing is re-resolved
between the prompt and the write.
Bulk operations itemise every row with its own diff rather than summarising a
count, because "12 transactions" is exactly the prompt a person waves through
unread.
import_transactionshas no knowable diff, so it names the linkedaccounts and says plainly that the result cannot be previewed.
The cache is encrypted at rest. It holds payees, memos, amounts and category
names. Payload columns are AES-256-GCM with the key in the credential store,
implemented as a SQLAlchemy
TypeDecoratorsosrc/cache/service.pyisuntouched.
Opt-in read-only mode.
YNAB_READ_ONLY=1drops the 16 write tools atregistration time, so they never reach the model's tool list. Off by default.
Honest limits (all stated in SECURITY.md)
a program. Code running as the same user can still retrieve the token. Closing
that needs a separate OS account, a hardware token, or a broker process —
none of which a pip-installable server can deliver.
mcp-ynab:<table>.<column>, which binds ciphertext to itstable and column but not to its row. Someone with DB write access could
swap ciphertexts between rows of one table. Acceptable (they already have
write access) and documented rather than hidden.
timing but no amounts, payees, memos or category names.
0600on POSIX only; on Windows the file inherits the user-profile ACL.Reviewer notes
The consent gate is only as strong as the client's elicitation support. On a
client that does not implement
elicitation/create, writes now refuse outrightrather than degrading. Verified in practice: Claude Code's terminal CLI supports
it (2.1.76+); Claude Desktop does not (anthropics/claude-code#41110), and the
desktop Code tab advertises the capability then auto-declines without rendering
a prompt (anthropics/claude-code#94450).
YNAB_READ_ONLY=1is the cleanconfiguration for those.
Two deviations from the original brief, both deliberate:
allow_env_keydefaults to true, not false. The brief specified= Falsewhile also requiring the env var be permitted with a deprecation warning;
those contradict. Defaulting it off would break
scripts/smoke_test.pyandevery existing install.
YNAB_ALLOW_ENV_KEY=0gives the strict behaviour.crypto.encrypt()produces exactlynonce(12) || ciphertext || tagasspecified, but the storage layer prefixes one framing byte so
YNAB_CACHE_ENCRYPTION=0can be toggled without stored rows decoding asgarbage.
auth login --stdinalso widens the brief, which said the token must only comefrom
getpass. It keeps the token out ofargvand shell history — theproperty that actually mattered — and exists because pasting into a hidden
prompt is broken on the Windows console (see below).
A migration bug worth knowing about. The first version of the cache
migration dropped
cached_entitybut leftserver_knowledge. Delta sync wouldthen have believed it was current, fetched only changes since that knowledge,
and the dropped entities would never have returned. The two now drop together.
Tool surface is unchanged: 47 tools, identical names and parameter schemas,
no
ctxleaking into any input schema.exclude_fieldsbehaviour andFIELDS.mdsemantics are preserved.Verification
uv run pytest— 153 passed (105 pre-existing, 48 new intests/test_security.py). Existing tests keep their assertions on exactlywhich payload reaches the cache service; they only gained the fixtures the
gate requires.
python scripts/smoke_test.py— passes onhighest(resolves mcp 2.2.0) and--resolution lowest-direct(mcp 2.0.0, keyring 25.0, cryptography 43.0).prompt renders, decline blocks the write, a client without elicitation is
refused, and
YNAB_READ_ONLY=1serves 31 tools instead of 47.opaque BLOB, lookup columns intact, plaintext absent from the bytes on disk.
The last two commits fix bugs found while setting this up on Windows for the
first time: a Ctrl+V control character reaching the Authorization header and
producing an opaque HTTP 400, a
sys.stdin.isatty()check that returns trueunder Git Bash even when redirected (so
auth loginhung forever instead oferroring), and PowerShell's UTF-8 BOM breaking
--stdin.🤖 Generated with Claude Code