# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 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 upgrade`** — `git -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 `properties`→`gradle-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 (`/.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 --key `** — 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 `** — 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 failure** — `secrets 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":}` or `{"type":"external","subtype":"properties"|"file","path":}`. Reflects the same recursive store walk as the human `list` (nested `/.age` + `external/.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 `.properties.age` in a v2 store (was `.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 `** — 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 push** — `secrets 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 (`/.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 `/external/`; pull restores it with mode 600, backing up a divergent existing target to `.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 `/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 `/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 ` 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 `` 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 `\t` 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 ` 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 ` to join an existing store, or `secrets --store init` to start fresh. - **Active-store echo.** `secrets push` and `secrets pull` print `==> Store: (from )` 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, F1–F5 adversarial regressions. [0.1.1.0]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.1.0 [0.1.0.1]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.0.1 [0.1.0.0]: https://codeberg.org/egbt/secrets/releases/tag/v0.1.0.0