Skip to main content
  • Status: accepted · Date: 2026-07-22 · Deciders: Davor Runje

Context

ADR-0029 put the CLI-owned key store at .defendable-science/keys.jsoninside 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 .gitignore happens 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-memory env for 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 $HOME isn’t durable).

Considered options

  1. Minimal: just add the gitignore line. research-init gitignores .defendable-science/keys.json, store location unchanged.
  2. 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)
  3. 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.json per the XDG Base Directory spec — never inside the consumer repo’s work tree. No dependency added (platformdirs was considered and rejected as unnecessary weight for two environment variables); implemented with stdlib os.environ + Path.home().
  • Opt-in override. DEFENDABLE_SCIENCE_KEYS_PATH is an explicit override — set to any path, including the legacy in-repo .defendable-science/keys.json (kept as keys.IN_REPO_STORE_PATH) — for anyone who wants the store to travel with the repo instead of $HOME. research-init continues 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 .git entry) and is not confirmed ignored (git check-ignore, run in that work tree’s root regardless of the CLI’s actual cwd). keys set calls this before writing and warns — never refuses — when it is true; an inconclusive answer (no git binary, 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 set on 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 set sequence can never produce a committable secret file — git status shows 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_PATH pointed at an un-ignored location; research-init re-runs will still add the gitignore line as a backstop.
  • One more environment variable to document (DEFENDABLE_SCIENCE_KEYS_PATH, alongside the existing DEFENDABLE_SCIENCE_LIVE).
  • The guardrail shells out to git check-ignore (a fixed, argument-list subprocess call, no shell) — the same trust boundary already accepted for rclone (ADR-0011) and the --version probes in doctor.
  • Tests that exercise the default path must isolate HOME/XDG_CONFIG_HOME (a tests/conftest.py autouse 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-init still 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.
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).