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
30 KiB
30 KiB
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.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:pushdemanded a fast-forward and pointed atpull, which could not fast-forward either, so the advice looped and recovery meant hand-running git next to a directory of encrypted blobs.secrets syncfetches, stashes uncommitted blob edits, rebases local commits onto the remote, restores the stash, and then asks before publishing local commits to the shared store.--yesskips the prompt (scripts/CI);--dry-runreports ahead/behind/dirty and what would happen, changing nothing. Deliberately non-destructive: no merge, no force-push, noreset --hard, nostash 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 newremote:line reports the store'sahead N, behind N, N modified(orup 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 pullno longer fails silently when the store can't sync (EGB-1230) — the store sync wasgit pull >/dev/null 2>&1underset -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 atsecrets sync.
Changed
secrets pull's store sync is now fast-forward only, matching the push path. A plaingit pullcould 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 syncand 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-Rformat, with# namecomment 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 listshows the current set (or a note that the store is still single-key). A newsecrets reencryptcommand re-encrypts every blob to the current recipients without changing the set (idempotent heal / backfill after a manual edit). Absence ofrecipients.txtpreserves exact legacy single-key behavior; the firstrecipients addon a legacy store bootstraps the file seeded with the local pubkey plus the new key.initnow seedsrecipients.txtborn-multi with the freshly generated pubkey.
Changed
secrets rekeyon a multi-recipient store no longer generates a new keypair — instead it re-encrypts all blobs to the currentrecipients.txtset (the shared_reencrypt_allengine). On a legacy store (norecipients.txt)rekeykeeps today's generate-new-keypair behavior.secrets whichnow prints arecipients: N (name, …)line, orrecipients: single-key (no recipients.txt)for a legacy store.secrets verify/verify --allassert that each blob's age recipient-stanza count equals the number of entries inrecipients.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 upgradeverb (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 upgradecloses that loop.secrets upgrade—git -C "$SCRIPT_DIR" pull --ff-onlyon the tool's own checkout (fast-forward only — never merges or rewrites local commits), reportsvOLD -> 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 whichnow reuses the one external-entry extractor the push/pull path uses (_json_external_entries) instead of its own duplicatedjqprojection. Sowhichapplies the sameproperties→gradle-propertiesnormalization and skips (with a warning) the same malformed external entries the sync path drops —whichshows 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_pushand_external_entries_for_pullcan't drift apart.
Fixed
- Legacy (manifest-less)
pullno 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_fileshandles those), pointing at committing a.secrets.jsonas 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 falseVERIFIED); 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 projectpushdoesn't trip the fast-forward-pull guard on a brand-new empty remote. Run interactively,initalso 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: checksage+jqgit, then prints thePATHline, the onboarding next-steps, the upgrade one-liner, and a key-transfer hint. It never edits your shell config and never runssudo(it prints the command so you stay in control).
- First-manifest
options.autoAddprompt (EGB-677 contract #2) — the firstpushthat scaffolds a project's manifest now records an explicit, committedoptions.autoAddvalue (asked once when interactive; the default ON, written explicitly, under automation).
Fixed
- Day-2 silent decrypt failure —
secrets pullnow 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 initsecond-machine trap now points atsecrets join(the real one-command path) instead of a manualgit 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 atypediscriminator —{"type":"dotenv","path":<relpath>}or{"type":"external","subtype":"properties"|"file","path":<slug>}. Reflects the same recursive store walk as the humanlist(nested<project>/<relpath>.ageexternal/<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
secretsversion that has written to it (.secrets-writer-version, committed, monotonic). When you run a command against a store last written by a newersecretsthan your own, you get a one-line non-fatal stderr nudge to update your tool;secrets whichshows the store'swritten-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
secretsclients now read either external blob suffix (.properties.ageor the legacy.gradle-properties.age) and dual-write apropertiesexternal whenever a v1 twin already exists in the store. Existing externals keep working for teammates on an oldersecrets; only a brand-newpropertiesexternal is written v2-only (a gentle "upgrade to see it" forcing function). dotenv and whole-fileexternals are unchanged across formats and always propagate. secrets migrate --finalizeis 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 --statusnow reportsv2-onlyexternals 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 migratecopy-forward is now manifest-free (EGB-710) — the per-project step enumerates the store's*.gradle-properties.ageblobs directly (the same source of truth--finalizeuses) 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--finalizewon'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 whichnow prints the manifest version (EGB-700) — the manifest header line showsversion Nalongside the store format, so a singlesecrets whichtells you both the on-disk store format and the.secrets.jsonschema 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-formatfile, andsecrets whichprints it (format: v2). A freshsecrets initcreates 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-runpreviews exactly what would change without writing anything.secrets migrate --finalize— the one destructive step, run once store-wide. It refuses unlesssecrets verifypasses and every blob has its new-format twin, cuts apre-v2-migrate-*recovery tag first, then drops the old blobs. It asks for confirmation (or--yes) because a machine still on an oldersecretswill stop seeing migrated external files until it updates.
Changed
- The external
propertiesblob is stored as<name>.properties.agein a v2 store (was<name>.gradle-properties.age), matching the manifesttype.push,pull, andverifypick 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.jsonagainst 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/nulland 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--allchecks decryptability only, not manifest consistency.)- Both modes recurse the whole project tree, so nested entries and external
files are covered.
secrets verifyexits non-zero on any problem, so it can gate CI or a future store migration.
[0.4.0.0] - 2026-06-07
Added
.secrets.jsonmanifest (EGB-677 stage 1) — a committed, project-root manifest is now the source of truth for what syncs. List the env files you want underdotenv[](project-relative, nested paths and@-scoped workspaces allowed;.., absolute, and symlink paths are rejected) and out-of-project files underexternal[](propertiesorfile). 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.jsonon first use, dedupes, and writes a stable canonical form.- Auto-add on push —
secrets pushdiscovers new.env*/.dev.varsfiles and adds them to the manifest (prints what it added and reminds you to commit). Gated byoptions.autoAddin the manifest (default on);push --frozensyncs only declared files, andpush --dry-runpreviews 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-filesabsorb — an existing.secrets-filesis folded into.secrets.jsonon first push (gradle-properties entries becomeproperties); 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
jqis required only when a manifest is present or being written; manifest-less projects keep working withoutjq(manifest features are skipped with a notice).
Fixed
- Key rotation no longer orphans nested or external blobs.
secrets rekeyandsecrets listnow walk the entire project tree, so nested manifest entries (<project>/<relpath>.age) andexternal/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
fileexternal type (EGB-652) —.secrets-filescan 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 asgradle-properties(inside$HOME, no.., no symlinks) minus the basename restriction. 7 new bats tests.
[0.2.1.0] - 2026-06-05
Fixed
secrets rekeyno longer bricks dotenv stores. The re-encrypt loop used a bare"$dir"*glob, which never matches dotfiles — so.envblobs were decrypted to the temp dir but never re-encrypted, leaving them on the old key while the new key overwrotekey.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 ranrekeyon an earlier version andpullnow fails withno identity matched any of the recipients, your blobs are on a pre-rotation key — recover with an oldkey.txtfrom another machine.secrets initon a second machine now fails helpfully instead of half-initializing. Copyingkey.txtinto~/.secretsand then runninginit(instead of cloning your secrets repo) used to rungit init, crash on the existing key, and leave a store with no.gitignore— a state where a laterpushwould commit the private key. The guard now fires beforegit init, leaves the key untouched, and prints the exactgit clonecommand to run — using the real remote URL when your.secrets-storefile declares one.
Security
- The private key can no longer be committed by a store missing its
.gitignore.push,pull, andrekeynow self-heal store protections immediately before anygit add -A: a missing or corrupted.gitignore(one without thekey.txtline) 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.gitignorecould strip protection mid-push. - An already-tracked
key.txtis now untracked automatically..gitignorecan'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.mdgained 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-filesmanifest letssecretstrack 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-bakbefore each merge. secrets whichreads back the parsed manifest;secrets listshows[external]entries;secrets rekeyre-encrypts external blobs alongside dotenv ones.- Backward compatible: no
.secrets-files→ identical behavior to before.
- push extracts only the named keys and encrypts them under
Security
- The merge is pure bash with exact-string key matching — no
sed/regex. This is deliberate: ased-based substitution would corrupt values containing&,\, or/(common in API keys) and would false-match substring keys (beaconClerkPkvsbeaconClerkPkTest). 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-storeposture. Writes are atomic (temp-in-same-dir + rename), mode-preserving, and default to600on create. - Storage isolation. External blobs live in a
<project>/external/subdir so the existing broad*.ageglobs (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 cleardoes not remove them) — appropriate for publishable/low-secrecy values like Clerk publishable keys, by design.
Fixed
secrets rekeywas broken and never completed. Two latent bugs, exposed by the new rekey test: (1)age-keygen -o key.txtaborts 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) theEXITtrap referenced the function-local$tmpdirafter the function returned, erroring underset -uand 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 mode600, manifest injection/symlink/unsafe-key rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd),listsurfacing, pre-commit blocking plaintextgradle.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:
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 actualwork git@github.com:acme/work-secrets.gitgit clone <url> <path>line — they no longer have to ask the original setter for the URL. The URL is optional; existing single-token.secrets-storefiles continue to work and show the<their-store-remote>placeholder as before. (EGB-282)
Security
- Hardened
.secrets-storeURL parser against copy-paste shell injection. The URL is rendered into agit cloneline that a teammate is likely to copy-paste from the directed error. Without sanitization, a malicious.secrets-storecontainingwork evil.git;rm -rf ~would render verbatim and executerm -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 -- $linetoread -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 whichnow prints the full path of the.secrets-storefile that won resolution. Before this fix,_find_secrets_store_fileset_LAST_FOUND_ATinside 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 thatresolve_storesplits in the parent shell. Caught by the/land-and-deploypost-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 pushandsecrets pullagainst 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-storefile 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 myappworks without touching files. Bare names like--store workexpand to$HOME/.secrets-work.--store defaultis sugar for~/.secrets.secrets whichprints the active store path and which rule chose it (flag,.secrets-storefile, env var, or default). Aliases:secrets where,secrets status.- Directed errors for teammate onboarding. When
.secrets-storeresolves to an uninitialized store or one missingkey.txt, the error message names both recovery paths:git clone <remote>to join an existing store, orsecrets --store <name> initto start fresh. - Active-store echo.
secrets pushandsecrets pullprint==> Store: <path> (from <source>)whenever a non-default store is active, so wrong-store mistakes surface immediately.
Changed
secrets listnow hints atsecrets whichwhen a non-default store is active.cmd_helpdocuments the four-rule resolution order (--store>.secrets-storefile >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-storeis literal-only. Noeval, no$VARinterpolation, no$(...)execution. A committed.secrets-storecontaining$(rm -rf ~)reads as plain text, not as a command. - Walk-up bounded by
$HOME.secretsnever reads$HOME/.secrets-store, never walks past$HOMEto/, and never follows symlinked.secrets-storefiles. Symlinks (potential supply-chain attack via committed link to~/.aws/credentialsor similar) are ignored. KEY_FILEre-derives after--storeswitches stores. Previously, callingsecrets pull --store otherwould have decrypted ciphertext from the new store using the default store's key. Nowsecretsupdates bothSECRETS_DIRandKEY_FILEtogether insideresolve_store().secrets runcleanup 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/codestill get plaintext cleared after the wrapped command exits.- Test isolation: the bats suite now sets
HOME=$TEST_TMPDIRso.secrets-storewalk-up cannot wander into the developer's real home directory. --storeflag value validation. Empty values (--store=) and flag-shaped values (--store --workspaces) are rejected with directed errors instead of silently mapping to~/.secrets--something.- Unset
HOMEis 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.