Skip to content

feat(secrets): the OS keychain as a place a credential can live, with .env still winning - #464

Merged
eaitbrahim merged 1 commit into
mainfrom
feat/keychain-secrets
Aug 20, 2026
Merged

feat(secrets): the OS keychain as a place a credential can live, with .env still winning#464
eaitbrahim merged 1 commit into
mainfrom
feat/keychain-secrets

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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 fetch needs 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

source who sets it
1 the real environment whoever launched the process
2 the .env file the operator, in a folder they chose
3 the OS keychain the first-run wizard

.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: a .env is 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 set reads the value back through the same resolver a real caller uses and says when something shadows what was just stored:

$ keel credentials set CDP_API_KEY --stdin
stored CDP_API_KEY in the OS keychain.
note: keel will still read CDP_API_KEY from your .env file, which takes precedence.
      Remove it there if you meant the keychain value to be used.

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 show reports set/unset and the source. Nothing else.
  • 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. It prompts with echo off, or reads stdin. Pinned off the function signature, so a --value option added later as a convenience fails a test rather than shipping.

A machine with no usable backend is detected, not 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 — exactly the kind of thing freezing breaks. So it was built and run:

$ ./keel credentials show
keychain: available (service 'keel-trader')
precedence: environment, then .env, then the keychain.

$ echo "frozen-secret-abc" | ./keel credentials set KEEL_FROZEN_PROBE --stdin
stored KEEL_FROZEN_PROBE in the OS keychain.       # round-tripped through the real macOS Keychain
                                                    # 0 occurrences of the 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 very code it claimed to test — _from_keychain replaced by a stub that never raised. It now patches keyring.get_password instead, with a second test proving that path is actually reached.

Verification

4064 passed, 3 skipped (26 new). ruff check 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.

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

… .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>
@eaitbrahim
eaitbrahim merged commit 91a31dd into main Aug 20, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat/keychain-secrets branch August 20, 2026 21:30
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