feat(secrets): the OS keychain as a place a credential can live, with .env still winning - #464
Merged
Merged
Conversation
… .env still winning Until now a credential lived in exactly one place: a git-ignored `.env` beside the deployment. That is a fine answer for an operator who chose the folder and can see the file. It is the wrong answer for the desktop product, where the person installing keel has no terminal, no editor open on a dotfile, and no way to create one -- and it is a worse answer than it needs to be even for an operator, because a plaintext secret at rest is a plaintext secret at rest. So there are three sources, and the ORDER is the whole design: the real environment, then the `.env` file, then the OS keychain. `.env` sits ABOVE the keychain deliberately. Every pre-existing deployment therefore behaves byte-identically -- the keychain only answers where the file was silent -- and a value the operator can SEE beats one they cannot when the two disagree. A stale keychain entry silently overriding an edited `.env` is a debugging session nobody should have to have. Nothing migrates: no credential is moved out of a file, and `delete_secret` never edits one, because a `.env` is the operator's own artifact. `ResolvedSecret` carries the SOURCE alongside the value, which is not decoration. "keel cannot see your key" and "keel is using a different key than the one you just typed" are the two support questions this exists to make answerable, and only the source tells them apart -- so `credentials set` reads the value back through the SAME resolver a real caller uses and says when something shadows what was just stored, rather than leaving the operator to find out at the first request that used the wrong key. NOTHING HERE PRINTS A VALUE. `__repr__` is overridden, because a dataclass that prints its own secret in a traceback has published it to every log that traceback reaches, and tracebacks travel further than anything else in a program. `credentials show` reports set/unset and the source and nothing else. And `credentials set` REFUSES to take the value as an argument -- a secret on a command line is in shell history, in `ps` output for every other process on the machine while it runs, and in any terminal recording -- so it prompts with echo off or reads stdin. That is pinned off the function signature, so a `--value` option added later as a convenience fails a test. A machine with no usable backend is detected rather than discovered: `keyring` always imports and selects `fail.Keyring`, whose every operation raises. `store_secret` refuses up front and names the `.env` alternative, because a form that appears to save a credential and does not is worse than one that says no. VERIFIED IN A FROZEN BUNDLE, not assumed. `keyring` selects its backend dynamically, which is exactly the kind of thing freezing breaks -- so it was built and run: the bundle reports `keychain: available`, and a secret round-trips through the real macOS Keychain and out again with no value in any output. Tests do NOT touch the real keychain: CI has no backend, and a suite that wrote to a developer's login keychain would be leaving state on their machine to make an assertion. One of them started out patching over the code it claimed to test; it now patches `keyring.get_password` instead, with a second test proving that path is actually reached. 4064 passed, 3 skipped (26 new). ruff clean repo-wide; mypy clean over keel + packages. `uv.lock` moves with the new dependency -- the inverse of the omission that shipped 0.10.0 DIRTY. Refs #437, #18. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Refs #437 (D4). Milestone: Desktop distribution (#18).
The next thing blocking a first run: after setup creates a config, a database and a rule library,
keel fetchneeds a CDP key — and a desktop user has no terminal, no editor open on a dotfile, and no way to create a.env.Three sources, and the order is the whole design
.envfile.envsits above the keychain deliberately. Every pre-existing deployment therefore behaves byte-identically — the keychain only answers where the file was silent — and a value the operator can see beats one they cannot when the two disagree. A stale keychain entry silently overriding an edited.envis a debugging session nobody should have to have.Nothing migrates. No credential is moved out of a file, and
delete_secretnever edits one: a.envis the operator's own artifact, and deleting a line out of a file someone hand-wrote, on their behalf, is not something a credential command should do.The source is carried, and that is not decoration
"keel cannot see your key" and "keel is using a different key than the one you just typed" are the two support questions this exists to make answerable, and only the source tells them apart.
So
credentials setreads the value back through the same resolver a real caller uses and says when something shadows what was just stored:Otherwise the operator finds out at the first request that used the wrong key.
Nothing here prints a value
__repr__is overridden. A dataclass that prints its own secret in a traceback has published it to every log that traceback reaches — and tracebacks travel further than anything else in a program.credentials showreports set/unset and the source. Nothing else.credentials setrefuses to take the value as an argument. A secret on a command line is in shell history, inpsoutput for every other process on the machine while it runs, and in any terminal recording. It prompts with echo off, or reads stdin. Pinned off the function signature, so a--valueoption added later as a convenience fails a test rather than shipping.A machine with no usable backend is detected, not discovered:
keyringalways imports and selectsfail.Keyring, whose every operation raises.store_secretrefuses up front and names the.envalternative, because a form that appears to save a credential and does not is worse than one that says no.Verified in a frozen bundle, not assumed
keyringselects its backend dynamically — exactly the kind of thing freezing breaks. So it was built and run:Tests do not touch the real keychain
CI has no backend, and a suite that wrote to a developer's login keychain would be leaving state on their machine to make an assertion.
One of them started out patching over the very code it claimed to test —
_from_keychainreplaced by a stub that never raised. It now patcheskeyring.get_passwordinstead, with a second test proving that path is actually reached.Verification
4064 passed, 3 skipped (26 new).
ruff checkclean repo-wide,mypyclean overkeel+packages.uv.lockmoves with the new dependency — the inverse of the omission that shipped 0.10.0DIRTY.Next
The browser form over this, so the first-run wizard can capture a key without a terminal — and then market data, which is the last mechanical step before a paper deployment actually does something.
🤖 Generated with Claude Code