- Status: accepted · Date: 2026-07-22 · Deciders: Davor Runje
Context
ADR-0029 put the CLI-owned key store at.defendable-science/keys.json — inside
the consumer repo’s work tree — relying on research-init to gitignore it.
Onboarding davorrunje/mononet surfaced that research-init never actually
added that entry: defendable-science keys path resolved to
.defendable-science/keys.json, and git check-ignore -v on it produced no
output — not ignored, therefore committable. keys set correctly takes values
from stdin/a hidden prompt (never argv), so a secret never lands in shell
history, but the at-rest store itself was one missed scaffolding step away
from landing in a commit (defendable-science#66). That is exactly the failure mode
ADR-0029 set out to avoid, and it contradicts the secret-hygiene stance applied
elsewhere in this repo (rclone.conf is gitignored and only
rclone.conf.example is tracked).
A gitignore entry is a single point of failure: it protects a fresh,
correctly-scaffolded repo, but a missing line, a hand-edited .gitignore, or a
consumer repo onboarded before the fix all leave the secret exposed with no
second line of defense.
Decision drivers
- A stored key must never be committable by default — not “committable
unless
.gitignorehappens to be right.” - Defense-in-depth: even a correct gitignore entry is one mistake away from a leak; prefer removing the repo from the equation entirely.
- Preserve ADR-0029’s other properties unchanged:
os.environ> store > unset precedence, stdin/hidden-prompt-only writes, scoped in-memoryenvfor child processes, plaintext-at-rest honesty. - Stay light-dependency (ADR-0024) — no new package for this.
- Don’t break an existing in-repo store outright; give it an explicit,
documented path back for anyone who wants it (e.g. a shared dev container
where
$HOMEisn’t durable).
Considered options
- Minimal: just add the gitignore line.
research-initgitignores.defendable-science/keys.json, store location unchanged. - Move the default store outside the repo (XDG config), keep the in-repo path as an explicit opt-in + a runtime guardrail that warns if a resolved store is ever inside a non-gitignored work tree. (chosen)
- OS secure store (Keychain / libsecret / Credential Manager) now.
Decision
Option 2.- Default store location.
defendable_science.core.keys.default_store_path()resolves to$XDG_CONFIG_HOME/defendable-science/keys.json, falling back to~/.config/defendable-science/keys.jsonper the XDG Base Directory spec — never inside the consumer repo’s work tree. No dependency added (platformdirswas considered and rejected as unnecessary weight for two environment variables); implemented with stdlibos.environ+Path.home(). - Opt-in override.
DEFENDABLE_SCIENCE_KEYS_PATHis an explicit override — set to any path, including the legacy in-repo.defendable-science/keys.json(kept askeys.IN_REPO_STORE_PATH) — for anyone who wants the store to travel with the repo instead of$HOME.research-initcontinues to gitignore.defendable-science/keys.json, so that opt-in still lands safely. - Runtime guardrail.
keys.store_at_risk(path)reports whether the resolved store sits inside a git work tree (walking up for a.gitentry) and is not confirmed ignored (git check-ignore, run in that work tree’s root regardless of the CLI’s actual cwd).keys setcalls this before writing and warns — never refuses — when it is true; an inconclusive answer (nogitbinary, not a git repo at all… i.e. any non-”definitely ignored” result) is treated as at-risk, matching the “no news is not good news for a secret” posture. Warn, not refuse, because: (a) the default path already keeps a fresh setup safe, so this only fires for an explicit opt-in a user made deliberately; (b) it matches the existing convention in this module (keys seton an unrecognised name warns but still stores) — CLAUDE.md’s failure-honesty stance asks for an explicit, actionable signal, not a silent success or a hard stop on a deliberate choice. - Everything else from ADR-0029 is unchanged: precedence, stdin-only writes, scoped child-process env, plaintext-at-rest disclosure.
Consequences
- A fresh
research-init+keys setsequence can never produce a committable secret file —git statusshows nothing, because the store never entered the work tree. - An existing consumer repo that already has an in-repo store is unaffected by
the code change alone (nothing migrates automatically) but gets a warning
the next time it writes if it opts into
DEFENDABLE_SCIENCE_KEYS_PATHpointed at an un-ignored location;research-initre-runs will still add the gitignore line as a backstop. - One more environment variable to document (
DEFENDABLE_SCIENCE_KEYS_PATH, alongside the existingDEFENDABLE_SCIENCE_LIVE). - The guardrail shells out to
git check-ignore(a fixed, argument-list subprocess call, no shell) — the same trust boundary already accepted forrclone(ADR-0011) and the--versionprobes indoctor. - Tests that exercise the default path must isolate
HOME/XDG_CONFIG_HOME(atests/conftest.pyautouse fixture does this for the whole suite) so the suite never touches a real developer’s~/.config.
Rejected alternatives
- Just add the gitignore line (option 1) — the minimal fix the issue
proposed, but it is exactly the single point of failure that caused #66 in
the first place: it protects only a freshly, correctly scaffolded repo.
Implemented anyway as defense-in-depth (
research-initstill gitignores the in-repo path), just not relied on as the primary control. - OS secure store now (option 3) — the right at-rest answer eventually, but heavy/platform-specific with a headless/CI fallback to design; already deferred to issue #49 by ADR-0029 and still the better place for it.
Links
defendable-science/defendable_science/core/keys.py (store_path, default_store_path,
store_at_risk, is_gitignored); defendable-science/defendable_science/cli.py (keys set’s _warn_if_store_committable); skills/research-init/SKILL.md;
ADR-0029 (the store this refines); ADR-0024 (light-dependency posture);
defendable-science#66 (this decision); defendable-science#49 (OS-keychain follow-up,
unaffected); defendable-science#65 (a related but distinct scaffolding-vs-runtime
mismatch for the dataset cache path, fixed separately — not in scope here).