ADR-0011: Keychain credentials module — implementation shape¶
Status: Accepted (2026-05-25)
Context: Personal-assistant upgrade Phase 0 track 3. Companion: ADR-0003 (parent design); personal-assistant-upgrade.md §4.3.
Context¶
ADR-0003 established what to do for personal-assistant OAuth
credentials: store tokens in the macOS Keychain via the keyring
library, non-secret config in plaintext JSON under
~/.iris/workspace/credentials/, single shared entry-point module
so connector skills never touch keyring directly. The shape of the
API and a handful of pragmatic implementation choices were left to the
implementation phase.
Phase 0 track 3 implemented the module
(src/iris_harness/identity/credentials.py, commit 3e2497a). Five
implementation decisions were resolved during that work that aren't
in ADR-0003 and would otherwise be invisible to future readers: this
ADR records them.
Decision¶
Five implementation decisions:
-
String-based token API (not a typed
TokenBundlePydantic model).save_token(provider, account, value: str)accepts any opaque string;load_tokenreturnsstr | None. Callers (gmail-inbox, gcal-events, etc.) decide whether the value is a raw access token, a JSON-encoded{access, refresh, expires_at}blob, or anything else. A typedTokenBundlemay be added later as an additive convenience if multiple connectors converge on a shared shape, but the credentials module itself stays string-typed. -
No master-key / file fallback when keyring is unavailable. On hosts without a keyring backend (headless Linux without DBus, for example), operations raise the underlying
keyringexception. Caller's responsibility to surface that to the user. This is the opposite stance fromsrc/iris_harness/governance/vault/store.py(which does have a master-key fallback for its different use case); ADR-0003 explicitly rejected the vault pattern for the personal-assistant scope, and adding a fallback would re-introduce it. -
list_providers()is filesystem-only. It enumerates*.config.jsonfiles in the credentials directory and returns provider names. It does NOT enumerate keychain entries (keyringhas no portable "list all" API across backends). Config-file presence is therefore the source of truth for "this provider is configured." If a token is missing for a provider whose config exists, that's a re-auth path the caller handles. -
chmod 700only on POSIX. Windows uses ACLs rather than POSIX modes; theos.chmodcall would silently no-op or raise depending on the Python build.os.name == "posix"gates the call so cross-platform behaviour stays predictable. Windows users get default-NTFS permissions, which on a single-user account already restrict access to the user's home directory; defence in depth is weaker but not absent. -
Two-store architecture: keychain for tokens, JSON for non-secret config. The two stores are kept structurally separate (different API calls, different backends) rather than collapsing config into a single keychain blob. Rationale: non-secret config benefits from being plain-text inspectable (
cat ~/.iris/workspace/credentials/gmail.config.json) for debugging, while secrets must stay in the keychain. Mixing them would force either secrets-in-file or non-secrets-in-keychain — both worse defaults than the split.
Alternatives considered¶
-
Typed
TokenBundlePydantic model in the credentials module. Stronger types and clearer caller contract; downside is every caller must use the same shape even when the connector's OAuth library prefers something different (e.g., google-auth'sCredentialsobject). Rejected for MVP; revisit if a real convergence emerges. -
Master-key fallback to encrypted file (vault-pattern). Would make headless deployments work without keyring. Rejected: violates ADR-0003's explicit non-goal for the personal-assistant scope; "single Mac" is the target. Headless deployments are a future concern.
-
list_providers()enumerating both filesystem AND keychain. Could detect "ghost" providers (token in keychain but no config file). Rejected:keyringhas no portable list API; would require backend-specific code. Single source of truth (filesystem) is simpler and ghost-token cleanup can be a CLI command later (iris auth clean-orphans). -
Encrypting the config JSON files at rest. Adds key-management complexity for marginal gain — config files contain no secrets by design.
chmod 700on the directory + FileVault on the disk covers the realistic threat. Rejected. -
Storing OAuth client_id + client_secret in keychain. Tempting because client_secret is technically a secret. Rejected: Google docs explicitly state that for installed/desktop apps the client_secret is "best-effort" since it's distributed in client binaries; treating it as a secret would create false security theatre. Keep both client_id and client_secret in config JSON for simplicity; rotate via the Google Cloud Console if compromised.
Consequences¶
Positive: - Module is ~105 LOC. Connector skills (gmail-inbox, gcal-events) get a four-function API that's trivial to wrap. - Filesystem layout is human-debuggable: cat the config JSON to see which providers are connected, when they were authorised, what scopes were granted. - Storage backend is swappable: the four function signatures stay stable even if we later route through a different keychain library or add fallback paths.
Negative:
- Headless servers without keyring are unsupported. Operations will
fail loudly rather than degrade gracefully. Acceptable for the
single-Mac target; flagged for future revisit if/when IRIS goes
multi-user.
- The string-based API gives no compile-time check that callers
store/retrieve compatible shapes. A first-time bug ("save_token
stored JSON, load_token treated as raw") is possible. Mitigated
by per-connector documentation and integration tests.
- Two-store architecture means callers must remember which API to
use for which fact (token vs config). Convention: anything that's
a "secret" rotates via save_token; everything else via
save_config.
Reversibility: high. The four-function API is small enough that
swapping implementations (e.g., introducing a TokenBundle model,
adding a master-key fallback, merging the two stores) is mechanical
work bounded to this module plus its caller sites.
Related ADRs¶
- ADR-0003 (parent) established the keychain-over-vault stance.
- A future ADR-0009-style implementation ADR for the
iris auth gmailCLI flow (Phase 7) will build on this module. governance/vault/store.pycontinues to handle the vault-master-key concern separately; see ADR for the original vault design.