0006 - Secrets storage
Status
Accepted.
Context
monarchmoney-cli holds one bearer secret: the Monarch Money session token, persisted in ~/.monarchmoney-cli/session.json. Anyone holding that token can act as the user against the Monarch API. Two questions follow: where does the secret live, and how does it stay out of plaintext when a user or agent prefers not to write it to disk.
An obvious option is the operating-system keychain (macOS Keychain, the Freedesktop Secret Service, Windows Credential Manager). But this CLI is agent-first: its primary callers are scripts, CI jobs, and unattended local agents. Keychain access on those platforms triggers an interactive unlock prompt on first use and after re-lock, which hangs a headless process with no one to answer it. A tool that blocks waiting for a GUI prompt is unusable in the environment it is built for.
Decision
The secrets contract is a 0600 file plus environment-variable indirection, consistent with the rest of the CLI fleet. There is no keychain integration.
- The session file is written atomically-in-effect with
0600permissions (owner read/write only) in a0700directory; the local SQLite cache is likewise0600in a0700directory. - The stored
tokenmay be a literal token, or the indirection formenv:NAME. When it isenv:NAME, the value is resolved from environment variableNAMEwhen the session is loaded. IfNAMEis unset, loading fails with an explicit error — the CLI never silently proceeds with an empty token. - Indirection is resolved once, in
auth.Store.Load, the single point where a stored session becomes usable credentials, so every caller (accounts,transactions,auth status,doctor, the cache sync) sees the same resolved token and the same failure.
Consequences
Positive
- Fully headless: no GUI prompt can ever block an unattended run. An agent can write
{"token":"env:MONARCH_TOKEN", ...}to a0600session file and supply the real token from the environment at run time, keeping the secret out of the file entirely. - The behaviour is identical across macOS, Linux, and Windows; there is no per-platform keychain code path to maintain or debug.
- A literal token continues to work unchanged, so existing sessions are unaffected.
Negative
- A literal token left in the session file is protected only by filesystem permissions, not by an OS-level encrypted store.
- The indirection resolves eagerly at load, so a session referencing an unset variable is an error rather than a fallback.
Mitigations
env:NAMEis the documented path for callers who do not want a plaintext token on disk; combined with full-disk encryption (FileVault, LUKS) it covers the at-rest threat model appropriate for a single-user local tool.- The unset-variable error names the exact variable, so the fix is obvious.