0030: Credentials live in the OS keychain behind a credentials service¶
Context and Problem Statement¶
Plugins and the planned capability adapters (ADR 0028) need API tokens
and similar secrets, and the daemon (ADR 0020 §10) already says secrets come from the OS
keychain through keyring, never from unit files. ADRs 0020 and 0028 are still proposed; this record stands on its
own and holds whatever becomes of them. Without one way to store and read secrets, each plugin invents its own: a
token file here, an environment variable there, a value in config.toml that ends up in a team repository. How do
modules and plugins get secrets, where are they kept, and how does a person see what is missing without seeing values?
Decision Drivers¶
- Secrets never sit in plaintext files Heinzelmännchen writes, and never appear in output, logs or results that an agent, a terminal recording or a bug report could carry (ADR 0027).
- One way to do a thing: modules get secrets the way they get everything else, injected by type (ADR 0005).
- Headless Linux machines and CI have no keychain, and must still work.
hz system doctoranswers "what is missing and what do I do" (ADR 0015).
Considered Options¶
- A
Credentialsservice over the OS keychain (keyring), with an environment fallback (chosen) - Per-plugin token files (for example
~/.config/hz/github.token, mode 0600): plaintext on disk, copied by backups and dotfile repositories, and every plugin reimplements reading, permissions and doctor checks. - Environment variables only: no storage at all, so secrets end up in shell profiles in plaintext, and a background service started by launchd or systemd does not see the shell's environment.
- An encrypted file owned by Heinzelmännchen: needs a key, which must live somewhere (the keychain again, or a passphrase prompt that breaks unattended runs); we would own a crypto format and its mistakes.
Decision Outcome¶
Chosen option: a Credentials service over the OS keychain, with an environment fallback, because the keychain is
the place each operating system built for this, and a service injected by type keeps modules simple and testable.
- The only way in.
heinzel.Credentialsis a service injected by type. A module reads secrets only through it, and only by passing theCredentialLetit declared:credentials.get(token),has(token),require(token). Finding a credential by name and service is forhz system credentialalone (itsCredentialService). Modules never read token files or secret environment variables themselves. - Not an isolation boundary. Services are a naming scheme. Every module and plugin runs in the same process with
the user's rights, so a plugin can read any credential: through
Credentials, throughkeyringdirectly, or by registering a keyring backend; on macOS any script the same Python runs can read theheinzel/*entries. Only install plugins you trust (SECURITY.md). The let-only API states intent and makes misuse visible in review; it does not prevent it. - Keys. A credential is keyed by service and name. The service defaults to the qualified name of the declaring
module (
github,system.credential); a module may name another qualified module name as service to share one credential, andmodule.credentialrejects anything else. In the keychain the entry isservice = "heinzel/<service>",username = <name>, so nothing collides with other applications. - Backend: OS keychains only, never a plaintext file. Under the default
[system.credential] backend = "auto",keyringis used only when it chose an operating system's keychain: macOS Keychain, Windows Credential Locker, Secret Service or KWallet, or a chainer of only those. Any other backend (itsfailornullplaceholder, a plaintext backend such as keyrings.alt's, one a plugin registers orPYTHON_KEYRING_BACKENDorkeyringrc.cfgselects) counts as no keychain. Then credentials come from environment variablesHZ_CREDENTIAL_<SERVICE>_<NAME>(upper case, every other character_), and doctor reports the fallback asdegraded.backend = "keyring"forces whatever keyring chose, and doctor reports a non-OS backend asdegraded;backend = "environment"forces the variables. The kernel never importskeyring; the backend lives insystem.credential, which is essential (it can't be disabled), because plugins depend on the publicCredentialstype it provides. - Declarations. A module declares what it needs:
token = module.credential("api-token", purpose="...")adds aCredentialLet(name, purpose, optional service).Credentialsrefuses lets no registered module declared, so the declarations are complete.hz system credential listand doctor read them without running module code; doctor names each credentialcredential <service>/<name>once, and reports declared credentials whose variable names collide in the environment fallback (foo.barandfoo-barboth becomeFOO_BAR). - Values never leave. No operation, log line, error message or
hz system infosection contains a value:list,setandremovereturn name, service, purpose, backend, presence and whether something was removed. Backend errors are re-raised asHeinzelErrorwithout the value or the original exception.setreads the value from standard input or a hidden prompt, never from the command line;setandremoveare CLI-only operations, so no MCP or REST request carries a value or removes one. Programs Heinzelmännchen starts (uv, git, copier) get an environment withoutHZ_CREDENTIAL_*variables. - Needs attention, not an error. A missing declared credential is a
degradedcheck with the command that fixes it. A credential the remote service rejects or that expired is the same normal state: the module reports it throughCredentials.attention(...), adegradedcheck with the same fix, or raises aHeinzelErrorthat says how to replace it, never a crash.
CredentialLet is a new let kind rather than a list inside the service, because declarations must be visible before
any code runs (like settings, ADR 0012), owned by the module that declares them, and
consumed by a built-in module (system.credential: list and its health check), the precedent
ADR 0015 set for HealthLet.
Consequences¶
- Good: plugins get secrets in one line, tests pass a memory backend, and a person sees what is missing in doctor.
- Good: macOS and Windows users store secrets where their other secrets are, protected by their login.
- Bad: credentials are not isolated between plugins; a malicious plugin can read all of them, as it can read every file the user can. The protection is choosing plugins (SECURITY.md), not this service.
- Bad: keychains are not portably enumerable through
keyring, solistshows declared credentials only; a value stored for a module that is no longer installed stays in the keychain until removed with the keychain's own tools. - Bad: on Linux without Secret Service, secrets live in environment variables, which other processes of the same user
can read; doctor says so.
setandremovecannot change the environment and say what to export instead. - Bad:
HZ_CREDENTIAL_A_B_Cis ambiguous between servicea-b+ namecand servicea+ nameb-c; doctor reports such collisions among declared credentials. - Neutral: a new runtime dependency,
keyring(MIT), with its platform helpers.
Confirmation¶
tests/unit/kernel/credentials_test.py: lets only, the environment backend, redacted backend errors, the subprocess environment.tests/unit/modules/credential_service_test.py: only OS keychains underauto(with a stand-in plaintext backend), a forced non-OS backend is degraded, lookup by name and service, collision and naming checks.tests/integration/credential_test.py: sets a credential in the memory and the environment backend and asserts its value is absent from everyhz system credential,doctor,info,modulesandconfig showoutput, from the MCP and REST heads and from logs, also when a backend's errors carry the value;setandremoveare CLI-only.tests/unit/kernel/kernel_test.py::test_a_plugin_sorting_before_a_builtin_cannot_take_over_its_service.- The import-linter contract "The kernel never imports keyring" (
pyproject.toml). - The
heinzel.testingpytest plugin forces the environment backend, so no test touches a real keychain;tests/unit/public_api_test.pysnapshotsCredentialsandCredentialLetin the public API.
More Information¶
- Decided by the maintainer (delegated: the maintainer's standing delegation for technical choices), 2026-10-24.
- ADR 0020 §10 (secrets through
keyring); ADR 0028 (adapters request credentials per access mode); SECURITY.md lists the measure. - Known exception:
system.pluginreads its optional GitHub token from settings (GH_TOKEN,GITHUB_TOKEN); moving it behindCredentialsis a follow-up. - Revisit when OAuth flows arrive: refresh tokens are credentials too, but the flow that obtains them is not decided here.