# 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.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