ADR 0013 — Credential storage: secure-backend-preferred, scope-consistent, never on argv
- Status: Accepted
- Date: 2026-07-01
- Tracking: legacy tracker 1726, legacy tracker 1753, legacy tracker 1765; legacy tracker 1718 / ADR 0009
Context
madtea authenticates to a forge with a token; where and how it is stored sets
the blast radius of a leak. Several incidents shaped the model: plaintext tokens
written even under a secure backend (legacy tracker 1726), a token exposed by
git config --get-regexp (legacy tracker 1753 / legacy tracker 1718), and a token read from the wrong
git-config scope so the wrong ciphertext was decrypted (legacy tracker 1765).
Decision
Prefer an OS secure backend, store and read at a consistent scope, and never put a credential on a command line.
- Backend precedence, chosen at
setup: keychain (macOS / libsecret) →pass→systemd-creds→ plaintext. The first three keep the token out of git config (keychain/passin the OS store;systemd-credsas host/TPM-bound ciphertext inmadtea.token-enc). Plaintextmadtea.tokenis written only when no secure backend is available or--insecure-storageis passed — a consented fallback with a loud warning, never silently written alongside a secure backend (legacy tracker 1726). - Scope consistency: a credential is read from the same git-config scope it
was written to.
SystemdCredsStorereadstoken-encstarting at the scope of the resolvedmadtea.credential-backendmarker (local beats global) rather than git’s merged view, so a stray per-repotoken-enccannot shadow a global one and cause the wrong token to be decrypted (legacy tracker 1765). Becausemadtea clonerecords the marker per-repo without copying the ciphertext, a local read that finds no ciphertext falls back to the global scope — the scope the credential was actually written to (legacy tracker 1810). A local ciphertext, when present, still wins. Store and Delete remain strictly single-scope. - Never credential-shaped on argv. Tokens are written through an on-disk
writer and handed to git via
GIT_ASKPASS, never as agit config <key> <value>argument or any command-line flag — so a token never appears inps//proc/<pid>/cmdlineor shell history. The agent’s read surface is additionally guarded by the non-overridable credential-read hook (ADR 0009):git config --get-regexp/--list/ credential--getare blocked so a token cannot land in a transcript.
Consequences
- Exposure of a secure-backend value is bounded.
token-encis host/TPM-bound ciphertext — useless off-host and to anyone below the same UID/root, whichSECURITY.mdplaces out of scope (legacy tracker 1753). The genuinely sensitive value is the plaintext fallback, and that is a documented, consented choice. - Scope drift is a correctness and security concern (wrong token = auth failure, or decrypting a stale/foreign credential); scope-consistency closes that class (legacy tracker 1765).
- Complements ADR 0009. That ADR guards the agent’s read surface; this one records the storage model. Together: don’t store it in the clear, don’t put it on argv, don’t let the agent dump it.
Alternatives considered
- Plaintext
madtea.tokenalways (simplest). Rejected: a readable token in~/.gitconfigis the largest blast radius; secure backends exist to avoid it. - Merged-view credential reads (find the token wherever it lives). Rejected (legacy tracker 1765): convenient, but a local value shadows the intended global one; scope-consistency is required for correctness.
- Encrypt the plaintext fallback too. Out of scope: the fallback exists precisely for hosts with no secure backend / no TPM, so there is no host-bound key to encrypt with — it is a consented, warned plaintext store.
Amendment (2026-07-10) — no plaintext fallback without explicit consent
Decided 2026-07-10 (“NO PLAINTEXT FALLBACK WITHOUT –insecure-storage”,
declared a HARD RULE): the automatic plaintext fallback when no secure backend
is available is removed. Consent is the
--insecure-storage flag, never the absence of alternatives:
- With no OS keychain, no
pass, and nosystemd-creds,auth login/setup refuses with an actionable error (install a backend, or pass--insecure-storage) — interactively it refuses before walking the user through token creation;saveConfigcarries the same guard for every non-interactive path. --insecure-storageremains the sole plaintext path, with the same loud warning as before.- The body text above describing the no-backend case as “a consented fallback
with a loud warning” is superseded by this amendment; the “Encrypt the
plaintext fallback too” alternative note now applies only to the
--insecure-storagepath. Locked byTestSaveConfig_NoBackendRefusesPlaintextandTestPromptToken_NoBackendRefuses.
Canonical source: docs/adr/0013-credential-storage-model.md in the madtea repo.