# 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.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 the first 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 → 104 (+24). 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`, symlink), first-create mode `600`, manifest injection/symlink rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), `list` surfacing, pre-commit blocking plaintext `gradle.properties`, 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