EGB-1230: `cmd_pull` synced the store with `git pull >/dev/null 2>&1` under `set -euo pipefail`. A store that couldn't fast-forward killed the script at that line with git's exit 128 and nothing on stdout or stderr — a banner, no restored files, no reason, and invisible in a pipeline. The sync now routes through `_store_sync_pull`, which guards the pull, captures git's output as the diagnosis, and dies naming the store path and `secrets sync`. It is now `--ff-only` to match the push path, so a plain pull can no longer manufacture a merge commit in the store. EGB-1231: once a store diverged there was no way out — push demanded a fast-forward and pointed at pull, which couldn't fast-forward either, so the advice looped and recovery meant hand-running git next to encrypted blobs. Adds `secrets sync`: fetch, stash, rebase onto the remote, restore the stash, then a confirmation-gated push of local commits (`--yes` to skip the prompt, `--dry-run` to report only). Non-destructive by construction — no merge, no force-push, no `reset --hard`, no `stash drop`; a rebase conflict names the conflicting files and leaves the store exactly as found. `secrets which` gains a `remote:` line reporting ahead/behind/dirty, and push's dead-end message now points at `sync`. test/sync.bats: 25 new tests. Full suite 353/353 green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrUoYuUMoTj91rzV4vxGPB
439 lines
30 KiB
Markdown
439 lines
30 KiB
Markdown
# 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.6.0] - 2026-09-08
|
||
|
||
### Added
|
||
|
||
- **`secrets sync` — reconcile a diverged store (EGB-1231)** — the store is a
|
||
git repo, and once a clone was both ahead and behind its remote the CLI had
|
||
no way out: `push` demanded a fast-forward and pointed at `pull`, which
|
||
could not fast-forward either, so the advice looped and recovery meant
|
||
hand-running git next to a directory of encrypted blobs. `secrets sync`
|
||
fetches, stashes uncommitted blob edits, rebases local commits onto the
|
||
remote, restores the stash, and then asks before publishing local commits to
|
||
the shared store. `--yes` skips the prompt (scripts/CI); `--dry-run` reports
|
||
ahead/behind/dirty and what would happen, changing nothing. Deliberately
|
||
non-destructive: no merge, no force-push, no `reset --hard`, no `stash
|
||
drop`. A rebase conflict aborts, restores the stash, names the conflicting
|
||
files, and leaves the store exactly as found.
|
||
- **Store state in `secrets which` (EGB-1231)** — a new `remote:` line reports
|
||
the store's `ahead N, behind N, N modified` (or `up to date`) against its
|
||
upstream, with a `(run: secrets sync)` hint when there is anything to
|
||
reconcile. Offline-safe (reports against the last fetch, never reaches the
|
||
network) and silent for a local-only store or one with no upstream.
|
||
|
||
### Fixed
|
||
|
||
- **`secrets pull` no longer fails silently when the store can't sync
|
||
(EGB-1230)** — the store sync was `git pull >/dev/null 2>&1` under `set -euo
|
||
pipefail`, so a store that could not fast-forward killed the script at that
|
||
line with git's exit 128 and *nothing* on stdout or stderr. The user saw a
|
||
banner, no restored files, and no reason — indistinguishable from a project
|
||
with nothing to pull, and easy to lose entirely in a pipeline. The sync is
|
||
now guarded, git's output is captured and surfaced as the diagnosis, and the
|
||
error names the store path and points at `secrets sync`.
|
||
|
||
### Changed
|
||
|
||
- **`secrets pull`'s store sync is now fast-forward only**, matching the push
|
||
path. A plain `git pull` could quietly manufacture a merge commit in the
|
||
store; divergence is now resolved in exactly one place — `secrets sync`.
|
||
- **The push path's dead-end advice** ("Run 'secrets pull' first, then retry
|
||
push") now points at `secrets sync` and includes git's own output.
|
||
|
||
## [0.7.5.0] - 2026-06-24
|
||
|
||
### Added
|
||
|
||
- **Multi-recipient age encryption (EGB-283)** — a store-scoped, committed
|
||
`recipients.txt` (age `-R` format, with `# name` comment lines) lets one
|
||
store encrypt every blob to N age public keys — one per team member.
|
||
`secrets recipients add <age1…> [--name N]` adds a key and immediately
|
||
re-encrypts the whole store; `secrets recipients rm <key|name> [--yes]`
|
||
removes one and re-encrypts; `secrets recipients list` shows the current
|
||
set (or a note that the store is still single-key). A new `secrets
|
||
reencrypt` command re-encrypts every blob to the current recipients without
|
||
changing the set (idempotent heal / backfill after a manual edit). Absence
|
||
of `recipients.txt` preserves exact legacy single-key behavior; the first
|
||
`recipients add` on a legacy store bootstraps the file seeded with the
|
||
local pubkey plus the new key. `init` now seeds `recipients.txt` born-multi
|
||
with the freshly generated pubkey.
|
||
|
||
### Changed
|
||
|
||
- **`secrets rekey` on a multi-recipient store** no longer generates a new
|
||
keypair — instead it re-encrypts all blobs to the current `recipients.txt`
|
||
set (the shared `_reencrypt_all` engine). On a legacy store (no
|
||
`recipients.txt`) `rekey` keeps today's generate-new-keypair behavior.
|
||
- **`secrets which`** now prints a `recipients: N (name, …)` line, or
|
||
`recipients: single-key (no recipients.txt)` for a legacy store.
|
||
- **`secrets verify` / `verify --all`** assert that each blob's age
|
||
recipient-stanza count equals the number of entries in `recipients.txt`
|
||
(skipped on legacy stores). Exits non-zero on any count mismatch so it can
|
||
gate CI or a migration.
|
||
|
||
## [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 (`<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 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":<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 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 (`<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, 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
|