secrets/CHANGELOG.md
Brian Majewski a17ae4448b chore: bump version and changelog (v0.7.4.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:01:17 -07:00

26 KiB
Raw Blame History

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.

[0.7.4.0] - 2026-06-18

Added

  • secrets upgrade verb (EGB-716) — the fix path paired with the EGB-713 version-skew warning. Until now the warning told you you were behind but not how to catch up; secrets upgrade closes that loop.
    • secrets upgradegit -C "$SCRIPT_DIR" pull --ff-only on the tool's own checkout (fast-forward only — never merges or rewrites local commits), reports vOLD -> vNEW, then best-effort re-checks the store's recorded writer-version against the new version so you see whether the EGB-713 nudge is now cleared (the new code itself takes effect on your next command).
    • secrets upgrade --check — reports whether an update is available (git fetch + compare to upstream) and changes nothing.
    • Deliberately thin: no auto-update, no background polling (this is a security tool). Directed errors for not-a-git-checkout, no upstream, a diverged/dirty branch, or being offline.

[0.7.3.1] - 2026-06-18

Changed

  • EGB-677 stage-1 structural cleanups (EGB-701) — tech-debt dedup with one new safety warning; no behavior change for the manifest-driven (v2) happy path.
    • secrets which now reuses the one external-entry extractor the push/pull path uses (_json_external_entries) instead of its own duplicated jq projection. So which applies the same propertiesgradle-properties normalization and skips (with a warning) the same malformed external entries the sync path drops — which shows exactly what will sync, not a stale raw projection that could drift from the real behavior.
    • The two external-manifest read guards are factored into shared helpers_json_readable (plain regular file, silent) and _legacy_readable (warns and skips a symlinked .secrets-files) — so _external_entries_for_push and _external_entries_for_pull can't drift apart.

Fixed

  • Legacy (manifest-less) pull no longer silently under-restores (EGB-701) — the manifest-less pull path globs only top-level *.age/.*.age, so a nested dotenv blob (<project>/<relpath>.age) written by a manifest-driven push on another machine was invisible: restored nothing, counted nothing, said nothing. It now warns and names each nested blob it can't reach (external blobs are excluded — pull_external_files handles those), pointing at committing a .secrets.json as the fix. The manifest-driven pull already restored nesting correctly; this only closes the legacy path's blind spot.

[0.7.3.0] - 2026-06-08

Added

  • Real install / onboarding scripts (EGB-671) — onboarding a machine is now (close to) one command, and a mis-copied key fails loudly instead of silently.
    • secrets join --remote <url> --key <path> — second-machine onboarding in one verb: clones the vault, installs the key at mode 600, and verifies the key actually decrypts the store before declaring success. An empty vault reports "nothing to verify yet" (it never prints a false VERIFIED); a wrong key fails loudly with the store left in place to fix. All security logic (store resolution, URL handling, path rails) is reused from the audited core, not re-implemented in a side script.
    • secrets init --remote <url> — wires the remote and pushes the initial store so the upstream branch exists, so your first project push doesn't trip the fast-forward-pull guard on a brand-new empty remote. Run interactively, init also offers to add your first project's secrets (default No, skipped under --yes / non-interactive, so it stays a clean primitive for CI).
    • install.sh — thin bootstrap that ships in the repo: checks age + jq
      • git, then prints the PATH line, the onboarding next-steps, the upgrade one-liner, and a key-transfer hint. It never edits your shell config and never runs sudo (it prints the command so you stay in control).
    • First-manifest options.autoAdd prompt (EGB-677 contract #2) — the first push that scaffolds a project's manifest now records an explicit, committed options.autoAdd value (asked once when interactive; the default ON, written explicitly, under automation).

Fixed

  • Day-2 silent decrypt failuresecrets pull now dies loudly when a blob fails to decrypt with the current key (all three decrypt paths), instead of emitting a warning and continuing with exit 0. A wrong key can no longer pass unnoticed after onboarding.
  • The secrets init second-machine trap now points at secrets join (the real one-command path) instead of a manual git clone.

[0.7.2.0] - 2026-06-08

Added

  • secrets list --json (EGB-699) — machine-readable listing for tooling and CI. Emits a single JSON object on stdout: {"store", "projects": [{"name", "entries": [...]}]}, where each entry self-describes via a type discriminator — {"type":"dotenv","path":<relpath>} or {"type":"external","subtype":"properties"|"file","path":<slug>}. Reflects the same recursive store walk as the human list (nested <project>/<relpath>.age
    • external/<slug>.age). jq does the assembly so paths escape correctly; the human store hint is suppressed so stdout stays pure JSON (notices → stderr). jq is required only for --json. Feeds the EGB-671 install scripts, which need to enumerate a cloned store programmatically instead of scraping the table.

[0.7.1.0] - 2026-06-08

Added

  • Version-skew nudge (EGB-713) — the store now records the highest secrets version that has written to it (.secrets-writer-version, committed, monotonic). When you run a command against a store last written by a newer secrets than your own, you get a one-line non-fatal stderr nudge to update your tool; secrets which shows the store's written-by: version (and flags when you're behind). Stores written by older builds carry no stamp and stay silent — no false alarms. The loud counterpart to EGB-712's quiet forcing function.

[0.7.0.0] - 2026-06-08

Changed

  • Additive store-format v2 (EGB-712) — upgraded secrets clients now read either external blob suffix (.properties.age or the legacy .gradle-properties.age) and dual-write a properties external whenever a v1 twin already exists in the store. Existing externals keep working for teammates on an older secrets; only a brand-new properties external is written v2-only (a gentle "upgrade to see it" forcing function). dotenv and whole-file externals are unchanged across formats and always propagate.
  • secrets migrate --finalize is now optional GC, not a required milestone. Because clients dual-write and read-fall-back, no teammate is ever cut off by not finalizing; finalize only reclaims the duplicate v1 blobs, and stays deferrable indefinitely. Its safety gates are unchanged. This defuses the cross-machine "all clients must be v2 before finalize" coordination gate.

Added

  • secrets migrate --status now reports v2-only externals per project (the ones an un-upgraded client cannot read), so you can see the forcing function's footprint at a glance.

[0.6.1.0] - 2026-06-08

Changed

  • secrets migrate copy-forward is now manifest-free (EGB-710) — the per-project step enumerates the store's *.gradle-properties.age blobs directly (the same source of truth --finalize uses) instead of reading .secrets.json. A legacy .secrets-files-only project now migrates cleanly instead of dead-ending with "No .secrets.json", and a store blob the manifest no longer declares still gets a v2 twin (so --finalize won't refuse it). Running migrate in a project with no v1 properties blobs is a clean no-op.

Added

  • secrets migrate --status — a read-only survey that walks every project in the store and reports its v2 readiness (v2-ready / migrated / NEEDS MIGRATE), then whether the store as a whole is finalize-ready. Exits non-zero while any v1 blob is un-twinned, so it can gate the path to --finalize.

[0.6.0.1] - 2026-06-08

Added

  • secrets which now prints the manifest version (EGB-700) — the manifest header line shows version N alongside the store format, so a single secrets which tells you both the on-disk store format and the .secrets.json schema version at a glance.

[0.6.0.0] - 2026-06-07

Added

  • Self-describing store format (v2) + secrets migrate (EGB-703) — the store now records its format in a committed .secrets-format file, and secrets which prints it (format: v2). A fresh secrets init creates a v2 store; existing stores read as v1 until migrated.
  • secrets migrate — copy-forward a project's encrypted blobs to the v2 layout. It is non-destructive: the old blobs are kept until you finalize, so a half-migrated store stays fully readable and recoverable. secrets migrate --dry-run previews exactly what would change without writing anything.
  • secrets migrate --finalize — the one destructive step, run once store-wide. It refuses unless secrets verify passes and every blob has its new-format twin, cuts a pre-v2-migrate-* recovery tag first, then drops the old blobs. It asks for confirmation (or --yes) because a machine still on an older secrets will stop seeing migrated external files until it updates.

Changed

  • The external properties blob is stored as <name>.properties.age in a v2 store (was <name>.gradle-properties.age), matching the manifest type. push, pull, and verify pick the right name automatically from the store format, so v1 and v2 stores both keep working during a migration.

[0.5.0.0] - 2026-06-07

Added

  • secrets verify (EGB-698) — a read-only integrity check. Run it in a project to cross-check the committed .secrets.json against the store both ways (entries declared but missing from the store, and stored blobs with no manifest entry) and decrypt-test every blob with your current key. Catches a partially-synced store, a stale key, or a manifest that has drifted from the store. Plaintext is streamed to /dev/null and never written to disk.
  • secrets verify --all — decrypt-tests every blob in every project in the store: a fast store-wide integrity sweep. (The store carries no manifests, so --all checks decryptability only, not manifest consistency.)
  • Both modes recurse the whole project tree, so nested entries and external files are covered. secrets verify exits non-zero on any problem, so it can gate CI or a future store migration.

[0.4.0.0] - 2026-06-07

Added

  • .secrets.json manifest (EGB-677 stage 1) — a committed, project-root manifest is now the source of truth for what syncs. List the env files you want under dotenv[] (project-relative, nested paths and @-scoped workspaces allowed; .., absolute, and symlink paths are rejected) and out-of-project files under external[] (properties or file). The manifest is shared across machines, so a teammate who clones the project sees exactly what to pull.
  • secrets add <path> — declare an env file in the manifest without pushing. Bootstraps .secrets.json on first use, dedupes, and writes a stable canonical form.
  • Auto-add on pushsecrets push discovers new .env* / .dev.vars files and adds them to the manifest (prints what it added and reminds you to commit). Gated by options.autoAdd in the manifest (default on); push --frozen syncs only declared files, and push --dry-run previews what would change without writing anything.
  • Manifest-driven pull — restores every declared file, recreating nested directories as needed, with the same path-safety rail applied at restore time so a malicious committed manifest can't write outside the project. An empty manifest is a safe no-op.
  • Legacy .secrets-files absorb — an existing .secrets-files is folded into .secrets.json on first push (gradle-properties entries become properties); on pull the legacy file is superseded with a warning.
  • Platform-aware install hints — missing-dependency errors now print the right install command for your platform (brew / apt-get / dnf).

Changed

  • jq is required only when a manifest is present or being written; manifest-less projects keep working without jq (manifest features are skipped with a notice).

Fixed

  • Key rotation no longer orphans nested or external blobs. secrets rekey and secrets list now walk the entire project tree, so nested manifest entries (<project>/<relpath>.age) and external/ blobs are re-encrypted and listed correctly. Previously a rekey could leave nested blobs encrypted under the discarded old key, making them permanently undecryptable.
  • Test assertions now fail correctly under system bash 3.2 (standalone [[ ]] checks no longer pass silently).

[0.3.0.0] - 2026-06-07

Added

  • file external type (EGB-652).secrets-files can now sync whole files outside the project root (binary-safe; built for the Beacon Android upload keystore): file ~/keystores/beacon-upload.keystore. Push encrypts the file verbatim into <project>/external/; pull restores it with mode 600, backing up a divergent existing target to <name>.secrets-bak. Same path safety rails as gradle-properties (inside $HOME, no .., no symlinks) minus the basename restriction. 7 new bats tests.

[0.2.1.0] - 2026-06-05

Fixed

  • secrets rekey no longer bricks dotenv stores. The re-encrypt loop used a bare "$dir"* glob, which never matches dotfiles — so .env blobs were decrypted to the temp dir but never re-encrypted, leaving them on the old key while the new key overwrote key.txt. After a rotation, every dotenv file in the store was undecryptable. The glob now mirrors the decrypt loop ("$dir"* "$dir".*), and a round-trip test (push → rekey → pull) pins it. If you ran rekey on an earlier version and pull now fails with no identity matched any of the recipients, your blobs are on a pre-rotation key — recover with an old key.txt from another machine.
  • secrets init on a second machine now fails helpfully instead of half-initializing. Copying key.txt into ~/.secrets and then running init (instead of cloning your secrets repo) used to run git init, crash on the existing key, and leave a store with no .gitignore — a state where a later push would commit the private key. The guard now fires before git init, leaves the key untouched, and prints the exact git clone command to run — using the real remote URL when your .secrets-store file declares one.

Security

  • The private key can no longer be committed by a store missing its .gitignore. push, pull, and rekey now self-heal store protections immediately before any git add -A: a missing or corrupted .gitignore (one without the key.txt line) is rewritten, and the pre-commit hook is reinstalled if absent. The heal runs after the fast-forward pull, closing a window where remote history without a .gitignore could strip protection mid-push.
  • An already-tracked key.txt is now untracked automatically. .gitignore can't untrack a file that was committed in the past; the heal now removes a tracked key from the index with a warning that history may need scrubbing and the key may warrant rotation.

Changed

  • Project CLAUDE.md gained agent skill-routing guidance and an updated test-suite count (126 bats tests, up from 113).

[0.2.0.0] - 2026-05-26

Added

  • Sync designated keys from external files (Gradle properties). A new committed .secrets-files manifest lets secrets track specific keys from files that live outside the project root — the motivating case being ~/.gradle/gradle.properties, where Android builds read Clerk publishable keys (beaconClerkPkTest, beaconClerkPkLive) that Android Studio's GUI builds can only get from that persistent global file, not from terminal env vars. One entry per line: gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive. (EGB-531)
    • push extracts only the named keys and encrypts them under <project>/external/ in the store.
    • pull merges those keys into the target file, preserving every unrelated key, comment, and line order. An existing managed key is updated in place; the target is backed up to gradle.properties.secrets-bak before each merge.
    • secrets which reads back the parsed manifest; secrets list shows [external] entries; secrets rekey re-encrypts external blobs alongside dotenv ones.
    • Backward compatible: no .secrets-files → identical behavior to before.

Security

  • The merge is pure bash with exact-string key matching — no sed/regex. This is deliberate: a sed-based substitution would corrupt values containing &, \, or / (common in API keys) and would false-match substring keys (beaconClerkPk vs beaconClerkPkTest). Values are treated as opaque literals and round-trip byte-exact.
  • External write targets are validated against an attacker-controlled path. Because the target path comes from a committed manifest, the writable target is locked down: basename must be gradle.properties, the path must resolve inside $HOME, .. traversal is rejected, and symlinked targets (file or parent dir) are refused — blocking a malicious manifest from appending decrypted keys to ~/.gitconfig, ~/.bashrc, etc. Manifest parsing rejects shell metacharacters and control characters in paths and keys, mirroring the .secrets-store posture. Writes are atomic (temp-in-same-dir + rename), mode-preserving, and default to 600 on create.
  • Storage isolation. External blobs live in a <project>/external/ subdir so the existing broad *.age globs (pull, list, rekey) structurally never decrypt them into the working directory or orphan them.
  • Note on plaintext. Merged Gradle keys are written as permanent plaintext into the target file (secrets clear does not remove them) — appropriate for publishable/low-secrecy values like Clerk publishable keys, by design.

Fixed

  • secrets rekey was broken and never completed. Two latent bugs, exposed by the new rekey test: (1) age-keygen -o key.txt aborts because age-keygen refuses to overwrite an existing file — the new key is now generated into a temp dir and moved into place only on success, so the old key survives a failed rotation; (2) the EXIT trap referenced the function-local $tmpdir after the function returned, erroring under set -u and leaking the plaintext temp dir — the temp dir is now removed explicitly and the trap cleared on normal completion.

Tests

  • 80 → 113 (+33). New coverage: manifest parse/read-back, key extraction across =/:/space separators, merge (preserve unrelated/comments/order, substring-key isolation, sed-metachar value round-trip, duplicate-key collapse, continuation-line safety, idempotency), path validation (wrong basename, outside $HOME, symlinked target, symlinked parent dir), first-create mode 600, manifest injection/symlink/unsafe-key rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), list surfacing, pre-commit blocking plaintext gradle.properties, workspace (push -w/pull -w) external sync, multi-entry manifests, partial-key push warnings, missing-blob pull warnings, source-side comment/continuation skipping, and backward compatibility.

0.1.1.0 - 2026-05-09

Added

  • Optional git remote URL in .secrets-store. Add a second whitespace-separated token after the store name to give teammates a copy-paste-ready clone command:
    work git@github.com:acme/work-secrets.git
    
    When a teammate clones a project bound to a store they don't have on their machine yet, the directed error now fills in the actual git clone <url> <path> line — they no longer have to ask the original setter for the URL. The URL is optional; existing single-token .secrets-store files continue to work and show the <their-store-remote> placeholder as before. (EGB-282)

Security

  • Hardened .secrets-store URL parser against copy-paste shell injection. The URL is rendered into a git clone line that a teammate is likely to copy-paste from the directed error. Without sanitization, a malicious .secrets-store containing work evil.git;rm -rf ~ would render verbatim and execute rm -rf ~ on paste. The parser now rejects URLs containing shell metacharacters (;&|<>$\(){}*?!"'\`), control characters (including ANSI escape sequences that could spoof terminal output), and embedded whitespace. Rejected URLs are dropped with a stderr warning; the directed error falls back to the safe placeholder. Found by adversarial review during /ship; verified with regression tests for every named attack vector.
  • Switched URL parsing from set -- $line to read -r spec rest. The previous form word-split and glob-expanded — work * from a populated directory would have leaked filenames into the URL field. The new form preserves the rest of the line verbatim into a single variable, so glob characters and internal whitespace are noticed by the sanitizer instead of silently expanded.

Tests

  • 72 → 80 (+8). New coverage: backward-compat single-token form, two-token URL form (SSH, HTTPS, ~/-prefixed), comment-and-URL form, copy-paste injection (rm -rf payload), backtick injection, $() injection, ANSI escape injection, multi-token URL, glob-character URL, and a positive test asserting standard git URL chars (-, +, _, :, /, @, .) round-trip unchanged.

0.1.0.1 - 2026-05-09

Fixed

  • secrets which now prints the full path of the .secrets-store file that won resolution. Before this fix, _find_secrets_store_file set _LAST_FOUND_AT inside a $(...) subshell, so the parent shell never saw it; the source line read .secrets-store file () with empty parens. The function now returns a tab-separated <dir>\t<source-file-path> tuple that resolve_store splits in the parent shell. Caught by the /land-and-deploy post-merge fresh-clone check; the existing test was too lenient and matched the truncated form. Test tightened to assert the full path appears in the parenthetical.

0.1.0.0 - 2026-05-09

Added

  • Multiple stores per user. Run secrets push and secrets pull against any encrypted store directory you choose, not just ~/.secrets/. Use cases: keep work secrets isolated from personal, run a separate store per client, or onboard a teammate to one project without giving them every other project's keys.
  • .secrets-store file for per-project bindings. Drop a one-line file at the project root (e.g. echo work > .secrets-store && git add .secrets-store && git commit) and every machine that clones the project automatically uses ~/.secrets-work/ for that repo. No env var to remember, no per-machine setup.
  • --store <dir> flag for one-shot overrides on any subcommand. secrets --store ~/.secrets-clientA pull myapp works without touching files. Bare names like --store work expand to $HOME/.secrets-work. --store default is sugar for ~/.secrets.
  • secrets which prints the active store path and which rule chose it (flag, .secrets-store file, env var, or default). Aliases: secrets where, secrets status.
  • Directed errors for teammate onboarding. When .secrets-store resolves to an uninitialized store or one missing key.txt, the error message names both recovery paths: git clone <remote> to join an existing store, or secrets --store <name> init to start fresh.
  • Active-store echo. secrets push and secrets pull print ==> Store: <path> (from <source>) whenever a non-default store is active, so wrong-store mistakes surface immediately.

Changed

  • secrets list now hints at secrets which when a non-default store is active.
  • cmd_help documents the four-rule resolution order (--store > .secrets-store file > SECRETS_DIR > default).
  • Error messages for missing init / missing key file are now context-aware: they distinguish between "default store on a fresh machine" and "non-default store referenced by .secrets-store."

Security

  • Path expansion in .secrets-store is literal-only. No eval, no $VAR interpolation, no $(...) execution. A committed .secrets-store containing $(rm -rf ~) reads as plain text, not as a command.
  • Walk-up bounded by $HOME. secrets never reads $HOME/.secrets-store, never walks past $HOME to /, and never follows symlinked .secrets-store files. Symlinks (potential supply-chain attack via committed link to ~/.aws/credentials or similar) are ignored.
  • KEY_FILE re-derives after --store switches stores. Previously, calling secrets pull --store other would have decrypted ciphertext from the new store using the default store's key. Now secrets updates both SECRETS_DIR and KEY_FILE together inside resolve_store().
  • secrets run cleanup survives paths with apostrophes. The EXIT trap is now a named function rather than a string-interpolated command, so projects at e.g. /Users/you/Mom's Mac/code still get plaintext cleared after the wrapped command exits.
  • Test isolation: the bats suite now sets HOME=$TEST_TMPDIR so .secrets-store walk-up cannot wander into the developer's real home directory.
  • --store flag value validation. Empty values (--store=) and flag-shaped values (--store --workspaces) are rejected with directed errors instead of silently mapping to ~/.secrets--something.
  • Unset HOME is detected with a directed error before the script tries to expand it. Helps cron, sudo without -H, and minimal CI runners.

Tests

  • 37 → 66 tests. New coverage: store resolution rules and precedence, walk-up boundaries, command-injection prevention, key-file re-derivation across stores, teammate-onboarding error path, monorepo workspace binding, F1F5 adversarial regressions.