Compare commits

..

No commits in common. "main" and "brian/egb-713-secrets-warn-when-the-store-was-written-by-a-newer-client" have entirely different histories.

17 changed files with 162 additions and 4817 deletions

View file

@ -5,216 +5,6 @@ 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.7.0] - 2026-09-08
### Fixed
- **pnpm monorepos discovered no workspace secrets (EGB-1232)** — both
workspace call sites resolved patterns from `package.json`'s `workspaces`
key only, which pnpm does not use (it declares `packages:` in
`pnpm-workspace.yaml`). `secrets push -w` refused outright; plain `secrets
push` failed *silently* — `_maybe_workspace_env_files` returned 0 the moment
the key was absent, so the auto-discovery that exists to cover push's
root-only scan was inert on every pnpm repo and printed "Nothing new to
add", indistinguishable from a repo that genuinely had nothing. Workspace
patterns now resolve through one shared source that falls back to
`pnpm-workspace.yaml`.
- **yarn's object `workspaces` form was never expanded (EGB-1232)** — the
filter `.workspaces // .workspaces.packages | .[]` short-circuits on yarn's
truthy object, so `.workspaces.packages` was never evaluated and `.[]`
iterated the object's values, yielding the pattern array itself as a single
token. Only npm's array form worked. Now type-aware, handling npm's array,
yarn's object, and absent/null alike.
### Added
- **`pnpm-workspace.yaml` support** — the `packages:` block sequence is read
without a YAML dependency: block form only, stopping at the next top-level
key so pnpm 10's `onlyBuiltDependencies:`/`catalog:` cannot leak in as glob
patterns, with quote and inline-comment handling and a symlink refusal.
`package.json` wins when it declares workspaces; `pnpm-workspace.yaml` is the
fallback. jq is now required only when `package.json` is the source, so a
pnpm-only repo resolves workspaces jq-free.
- **Workspace patterns are validated before glob expansion** — no absolute
paths, `..` traversal, shell metacharacters, or whitespace reach the
unquoted expansion; pnpm `!` negations are skipped. Same conservative rail
as `.secrets-store` / `.secrets-files`.
- **A monorepo-shaped root that resolves no workspaces now says so** — if a
`pnpm-workspace.yaml` or `packages/` directory is present but no workspace
packages can be read, `push` warns on stderr and points at `secrets add`,
instead of returning in silence. `secrets push -w`'s error now names
`pnpm-workspace.yaml` when that is the file present, rather than blaming a
`package.json` the repo may not use for workspaces.
## [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
@ -475,6 +265,6 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
- 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, F1F5 adversarial regressions.
[0.1.1.0]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.1.0
[0.1.0.1]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.0.1
[0.1.0.0]: https://git.dev.egbt.com/egbt/secrets/releases/tag/v0.1.0.0
[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

View file

@ -16,7 +16,7 @@ cd ~/my-project && ./secrets pull # Pull + decrypt .env* files
```bash
brew install bats-core
bats test/ # runs secrets.bats + manifest.bats + migrate.bats + upgrade.bats + recipients.bats
bats test/ # runs secrets.bats + manifest.bats + migrate.bats
./test/run-security.sh # security regression subset + operator sign-off (see below)
```
@ -56,36 +56,17 @@ skips security specialist + red team, and Step 11 skips adversarial review.
## Architecture
Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate, recipients, reencrypt, sync, upgrade.
Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rekey, verify, migrate.
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
- Storage: Private git repo at `~/.secrets/`
- Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`)
- Manifest (EGB-677 stage 1): committed `.secrets.json` is the source of truth for what syncs — `dotenv[]` (project-relative, nested ok, `@` allowed; rail rejects `..`/absolute/symlink) + `external[]` (`properties`/`file`). Push discovery auto-adds (gated by committed `options.autoAdd`, default ON; `--frozen`/`--dry-run` overrides), bootstraps the manifest on first push (written only after ≥1 blob encrypts), and absorbs a legacy `.secrets-files` (gradle-properties → `properties`; on pull the legacy file is superseded with a warning). Store layout: nested dotenv entries land at `<project>/<relpath>.age` (relpath preserved — the store self-describes where a file restores). jq is a hard dep only when a manifest exists/is written; manifest-less projects run jq-free (manifest features skipped with a notice). `check_cmd` prints platform-aware install hints.
- Store format (EGB-677 stage 2 / EGB-703): the store is self-describing via a committed one-line `$SECRETS_DIR/.secrets-format` file (`2`). Absence ⇒ v1 (every store predating EGB-703). v2's only on-disk change vs v1 is the external `properties` blob suffix: `.gradle-properties.age``.properties.age` (matching the manifest `type`); dotenv and `file` blobs are unchanged. `_store_format()` reads the marker. **Additive v2 (EGB-712):** reads resolve a `properties` blob by trying `.properties.age` then falling back to `.gradle-properties.age` (`_resolve_external_blob_read`); writes dual-write a `properties` external only when a v1 twin already exists in the store (`_external_blob_write_targets`), so existing externals keep old clients fresh while brand-new externals are written v2-only (a gentle forcing function). Blob location no longer depends on the marker — the old `_external_blob_suffix` is gone. `init` stamps a fresh store v2 (born-v2). `secrets which` prints the store-format line `format: vN`, and (EGB-700) when a `.secrets.json` is present the manifest header line also carries its schema version (`manifest (.secrets.json at <path>, version N):`). **Migration is copy-forward and non-destructive:** `secrets migrate --dry-run` (per project, reports old→new, writes nothing) → `secrets migrate` (per project, manifest-free: enumerates the store's `*.gradle-properties.age` blobs directly — same source of truth as `--finalize` — and writes their `.properties.age` twins, so a legacy `.secrets-files`-only project with no `.secrets.json` migrates cleanly and no store blob is left un-twinned; idempotent; EGB-710) → `secrets migrate --finalize` (store-wide; the ONLY destructive step — gates on `verify --all` green + every v1 blob having a v2 twin, cuts a `pre-v2-migrate-<sha>` recovery tag, stamps the marker, then drops v1 blobs; refuses without `--yes`/operator confirmation since a lagging v1 client against a finalized store stops seeing `properties` externals until it upgrades). `secrets migrate --status` is a read-only survey that walks every project in the store and reports each one's v2 readiness (v2-ready / migrated / NEEDS MIGRATE, plus a `v2-only` count of externals old clients can't read), exiting non-zero while any v1 blob is un-twinned so it gates the path to `--finalize` (EGB-710/EGB-712). **Under additive v2 (EGB-712) `--finalize` is now OPTIONAL GC, not a required milestone:** because upgraded clients dual-write existing externals and read-fall-back, *not* finalizing never cuts anyone off — finalize only reclaims the duplicate v1 blobs and stays deferrable indefinitely (defusing the cross-machine coordination gate). dotenv and `file` blobs are identical across formats, so they always propagate to old clients; only a brand-new `properties` external is v2-only. **Version-skew nudge (EGB-713):** a committed `$SECRETS_DIR/.secrets-writer-version` records the highest client `VERSION` that has written to the store (monotonic; stamped via `_stamp_writer_version` right before each store-committing `git add -A` — push/rekey/migrate/finalize — never on read paths, so it always rides a commit and never dangles to break `pull --ff-only`). `check_initialized` calls `_check_store_version_skew`, which warns once per invocation (stderr, non-fatal, `set -e`-safe) when the store's stamp is numerically greater than `_client_version` (read from `$SCRIPT_DIR/VERSION`); `secrets which` prints the `written-by:` line. Stores with no stamp (pre-EGB-713) are silent. The deliberate flatten-to-basename naming the EGB-677 CEO plan sketched was dropped as lossy (it discards the restore relpath that makes the store self-describing) — see the EGB-703 eureka. **Upgrade verb (EGB-716):** `secrets upgrade` is the fix path paired with the EGB-713 skew *warning* — it `git -C "$SCRIPT_DIR" pull --ff-only`s the tool's own checkout (fast-forward only, never merges/rewrites local commits), reports `vOLD -> vNEW`, then best-effort re-checks `_store_writer_version` against the new on-disk version so the operator sees whether the nudge is cleared (the new code takes effect next invocation). `secrets upgrade --check` does `git fetch` + `rev-list --count HEAD..@{u}` and reports availability without pulling. Deliberately thin: no auto-update, no background polling (security tool). Directed errors for not-a-git-checkout / no-upstream / diverged / offline. `cmd_upgrade` never calls `check_initialized` (it's about the tool, not the store); the skew re-check is silent unless a store with a writer-version resolves.
- Store sync + divergence (EGB-1230/EGB-1231): the store is a git repo, so a clone can end up ahead of and behind its remote at once. **EGB-1230:** `cmd_pull`'s sync used to be `git pull >/dev/null 2>&1` under `set -euo pipefail` — a store that couldn't fast-forward killed the script there with git's exit 128 and nothing on either stream (a banner, no files, no reason; invisible in a pipeline). It 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`. That sync is **`--ff-only`**, matching the push path — a plain `git pull` could quietly manufacture a merge commit in the store, and divergence is now resolved in exactly one place. **EGB-1231:** `_store_git_state` emits `ahead\tbehind\tdirty` (from `rev-list --left-right --count @{u}...HEAD` plus `status --porcelain`) and `_format_store_state` renders it; `cmd_which` prints a `remote:` line from them — offline-safe (reports against the last fetch), silent with no remote/upstream. `cmd_sync` is the reconcile verb the CLI was missing: fetch → report state → stash (`push -u`) → `rebase @{u}` → restore stash → `ensure_store_protections` (rebased-in history may lack `.gitignore`, and a store missing the `key.txt` line would stage the private key — same reasoning as push) → **confirmation-gated** `git push` of local commits. The gate (`_sync_confirm_push`) reads `/dev/tty` and requires a tty, so it stays CLOSED in scripts/CI rather than publishing to a shared store by default; `--yes` opens it, `--dry-run` reports and returns before any mutation. Non-destructive by construction: no merge, no `--force`, no `reset --hard`, no `stash drop`. A rebase conflict collects the conflicting paths BEFORE `rebase --abort` (the abort clears them), restores the stash, and dies — store byte-identical to how it was found. `_sync_restore_stash` never drops the stash on a failed pop; it tells the operator where their only copy lives. `cmd_sync` does not `_stamp_writer_version`: it replays existing commits rather than authoring content, and the stamp is specified to ride a store-committing `git add -A`. Test suite: `test/sync.bats` (25 tests), including a grep over the `cmd_sync` body asserting the destructive git verbs never appear in it.
- Store format (EGB-677 stage 2 / EGB-703): the store is self-describing via a committed one-line `$SECRETS_DIR/.secrets-format` file (`2`). Absence ⇒ v1 (every store predating EGB-703). v2's only on-disk change vs v1 is the external `properties` blob suffix: `.gradle-properties.age``.properties.age` (matching the manifest `type`); dotenv and `file` blobs are unchanged. `_store_format()` reads the marker. **Additive v2 (EGB-712):** reads resolve a `properties` blob by trying `.properties.age` then falling back to `.gradle-properties.age` (`_resolve_external_blob_read`); writes dual-write a `properties` external only when a v1 twin already exists in the store (`_external_blob_write_targets`), so existing externals keep old clients fresh while brand-new externals are written v2-only (a gentle forcing function). Blob location no longer depends on the marker — the old `_external_blob_suffix` is gone. `init` stamps a fresh store v2 (born-v2). `secrets which` prints the store-format line `format: vN`, and (EGB-700) when a `.secrets.json` is present the manifest header line also carries its schema version (`manifest (.secrets.json at <path>, version N):`). **Migration is copy-forward and non-destructive:** `secrets migrate --dry-run` (per project, reports old→new, writes nothing) → `secrets migrate` (per project, manifest-free: enumerates the store's `*.gradle-properties.age` blobs directly — same source of truth as `--finalize` — and writes their `.properties.age` twins, so a legacy `.secrets-files`-only project with no `.secrets.json` migrates cleanly and no store blob is left un-twinned; idempotent; EGB-710) → `secrets migrate --finalize` (store-wide; the ONLY destructive step — gates on `verify --all` green + every v1 blob having a v2 twin, cuts a `pre-v2-migrate-<sha>` recovery tag, stamps the marker, then drops v1 blobs; refuses without `--yes`/operator confirmation since a lagging v1 client against a finalized store stops seeing `properties` externals until it upgrades). `secrets migrate --status` is a read-only survey that walks every project in the store and reports each one's v2 readiness (v2-ready / migrated / NEEDS MIGRATE, plus a `v2-only` count of externals old clients can't read), exiting non-zero while any v1 blob is un-twinned so it gates the path to `--finalize` (EGB-710/EGB-712). **Under additive v2 (EGB-712) `--finalize` is now OPTIONAL GC, not a required milestone:** because upgraded clients dual-write existing externals and read-fall-back, *not* finalizing never cuts anyone off — finalize only reclaims the duplicate v1 blobs and stays deferrable indefinitely (defusing the cross-machine coordination gate). dotenv and `file` blobs are identical across formats, so they always propagate to old clients; only a brand-new `properties` external is v2-only. **Version-skew nudge (EGB-713):** a committed `$SECRETS_DIR/.secrets-writer-version` records the highest client `VERSION` that has written to the store (monotonic; stamped via `_stamp_writer_version` right before each store-committing `git add -A` — push/rekey/migrate/finalize — never on read paths, so it always rides a commit and never dangles to break `pull --ff-only`). `check_initialized` calls `_check_store_version_skew`, which warns once per invocation (stderr, non-fatal, `set -e`-safe) when the store's stamp is numerically greater than `_client_version` (read from `$SCRIPT_DIR/VERSION`); `secrets which` prints the `written-by:` line. Stores with no stamp (pre-EGB-713) are silent. The deliberate flatten-to-basename naming the EGB-677 CEO plan sketched was dropped as lossy (it discards the restore relpath that makes the store self-describing) — see the EGB-703 eureka.
- Verify (EGB-698): `secrets verify` is a read-only integrity check. Default mode (current project) cross-checks `$PWD/.secrets.json` against `$SECRETS_DIR/<project>/` both ways (declared-but-missing blobs + orphaned blobs) and decrypt-tests every blob (dotenv + external) by streaming plaintext to `/dev/null` (never written to disk). `secrets verify --all` decrypt-tests every blob in every project (integrity only — the store carries no manifests, so consistency can't be checked store-wide). Both recurse the whole project tree (`find -type f`, same as rekey/list). Exits non-zero on any finding so it can gate the stage-2 `migrate --finalize` and CI. The store deliberately holds no manifest — `.secrets.json` is committed in each project's own repo and read from `$PWD`.
- External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`, merged not overwritten — EGB-531) and whole binary files (type `file`, e.g. an Android upload keystore — EGB-652); see below
- Workspaces (EGB-1232): `--workspaces` and the plain-push workspace re-scan both resolve patterns through ONE source — `_workspace_patterns()`. It reads `package.json` `.workspaces` via `$WORKSPACES_JQ` (type-aware: handles npm's array AND yarn's object `{packages:[...]}` form) and falls back to `pnpm-workspace.yaml`'s `packages:` block when package.json declares none. **Two defects fixed:** (1) both call sites were package.json-only, so no pnpm monorepo ever resolved a workspace — and `_maybe_workspace_env_files` failed *silently* (`jq -e '.workspaces' ... || return 0`), making auto-discovery inert and `push` print "Nothing new to add", indistinguishable from a repo with nothing new; it bit the same repo twice. (2) the old filter `.workspaces // .workspaces.packages | .[]` short-circuits on yarn's truthy object, iterating the object's values and yielding the pattern ARRAY as a single token. Note a naive reorder does NOT fix it — `.workspaces.packages` errors on an array; hence the `if type == "object"` form. `_pnpm_workspace_packages()` is a deliberate non-parser (block sequence only, stops at the next top-level key so pnpm 10's `onlyBuiltDependencies:`/`catalog:` can't leak in as globs, strips quotes/inline comments, refuses a symlinked file). Patterns are validated by `_valid_workspace_pattern` before they reach the unquoted `for pattern in $patterns` glob expansion (no absolute/`..`/metacharacters/whitespace; pnpm `!` negations skipped) — same posture as `.secrets-store`/`.secrets-files`. `_looks_like_monorepo` + `_workspace_source` turn the old silent return into a warning that names the real file, and `get_workspaces`'s error names `pnpm-workspace.yaml` when that's what's present instead of blaming package.json. jq is required only when package.json is the source. **Scope note:** the workspace re-scan still runs only for projects that already have a `.secrets.json` — push's root-scan-only behavior on a first push is by design (EGB-677 E13), and EGB-1232 is about the fallback that covers it never engaging. Tests: `test/workspaces.bats` (18).
- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
- Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`)
- Multi-recipient (EGB-283): a store-scoped, committed `recipients.txt` (age `-R`
format, `# name` comments) lets one store encrypt every blob to N age keys —
one per team member. Managed via `secrets recipients add/rm/list`; absence of
the file ⇒ legacy single-key behavior (recipients = the pubkey derived from
`key.txt`). The file is parsed by us (never `age -R <path>`) into a validated
`RECIPIENT_ARGS` array (native age X25519 only, `age1[0-9a-z]{58}`; SSH
recipients rejected; symlinked file refused) — same conservative posture as
`.secrets-store`/`.secrets-files`. `_load_recipients` populates the array;
every encrypt site routes through it. Any recipient change re-encrypts the
WHOLE store in one commit via the shared `_reencrypt_all` engine (also used by
the new `secrets reencrypt` and by `rekey` on a multi-recipient store, where
rekey re-encrypts to the set with NO new keypair; legacy stores keep rekey's
generate-new-keypair behavior). `init` seeds `recipients.txt` born-multi.
`which` prints `recipients: N`; `verify`/`verify --all` assert each blob's
age recipient-stanza count equals `recipients.txt`'s length. Removal takes
effect going forward — git history stays readable by an old key, so rotate
genuinely-sensitive values. Decryption is unchanged (each member uses their
own `key.txt`).
- Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features
## Project Structure
@ -94,13 +75,9 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
secrets # CLI script (~2000 lines bash)
hooks/pre-commit # Pre-commit hook template
test/
secrets.bats # bats-core test suite (140 tests)
manifest.bats # EGB-677 .secrets.json manifest tests (83 tests)
migrate.bats # EGB-703 store-format-v2 migration tests (35 tests)
upgrade.bats # EGB-716 `secrets upgrade` self-update tests (8 tests)
sync.bats # EGB-1230/1231 store sync + divergence reconcile tests (25 tests)
workspaces.bats # EGB-1232 npm/yarn/pnpm workspace discovery tests (18 tests)
recipients.bats # EGB-283 multi-recipient age encryption tests (34 tests)
secrets.bats # bats-core test suite (133 tests)
manifest.bats # EGB-677 .secrets.json manifest tests (78 tests)
migrate.bats # EGB-703 store-format-v2 migration tests (26 tests)
test_helper.bash # Shared setup/teardown
README.md # User-facing documentation
CLAUDE.md # This file
@ -130,7 +107,7 @@ The active store directory is picked by `resolve_store()` using these rules, hig
Key design decisions (all driven by /autoplan review):
- **Wire-in is at command scope** (`cmd_push`/`cmd_pull`), via `push_external_files` / `pull_external_files`, **not** inside `push_dir_to_project` / `pull_project_to_dir` (those loop per-workspace and `pull_project_to_dir` uses stdout as a data channel).
- **Storage:** blobs live in `$SECRETS_DIR/<project>/external/<slug>.gradle-properties.age`. The `external/` subdir keeps them out of the legacy non-recursive `*.age` / `.*.age` globs the dotenv `pull` path uses, so a dotenv pull can never decrypt an external blob into cwd. `cmd_rekey` and `cmd_list` instead walk the **entire** project tree (`find -type f`), so they cover both `external/<slug>.age` and nested manifest dotenv blobs (`<project>/<relpath>.age`) — rekey MUST recurse, or any nested/external blob is orphaned under the old key after rotation = data loss (EGB-677 regression test: "rekey re-encrypts a nested manifest dotenv blob"). **EGB-701 cleanups:** (1) the *legacy* (manifest-less) `pull` keeps its non-recursive globs but now **warns** when nested `<project>/<relpath>.age` blobs exist that those globs can't see (it excludes `external/`, which `pull_external_files` handles) — so a manifest-less pull never silently under-restores; the fix the warning points at is committing a `.secrets.json`. (2) `cmd_which`, push, and pull share one external extractor (`_json_external_entries`), so `which` applies the same `properties``gradle-properties` normalization and skip-with-warning rules the sync path does (it shows exactly what will sync, not a stale raw projection). (3) the two external-manifest read guards are factored into `_json_readable` (plain regular file, silent) / `_legacy_readable` (warn-and-skip on a symlinked legacy manifest). `<slug>` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path). `cmd_list --json` (EGB-699) emits the same recursive walk as a machine-readable object (`{store, projects[].entries[]}`, each entry `dotenv``path` or `external``subtype`+`path`) for tooling/CI (feeds EGB-671); jq assembles it so paths escape correctly and stdout stays pure JSON (the human store hint is suppressed; jq is a hard dep only in `--json` mode).
- **Storage:** blobs live in `$SECRETS_DIR/<project>/external/<slug>.gradle-properties.age`. The `external/` subdir keeps them out of the legacy non-recursive `*.age` / `.*.age` globs the dotenv `pull` path uses, so a dotenv pull can never decrypt an external blob into cwd. `cmd_rekey` and `cmd_list` instead walk the **entire** project tree (`find -type f`), so they cover both `external/<slug>.age` and nested manifest dotenv blobs (`<project>/<relpath>.age`) — rekey MUST recurse, or any nested/external blob is orphaned under the old key after rotation = data loss (EGB-677 regression test: "rekey re-encrypts a nested manifest dotenv blob"). `<slug>` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path).
- **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `<target>.secrets-bak` before each merge.
- **Properties separator parsing** (`_props_get`): key ends at the first `=`, `:`, or whitespace (after lstrip); handles `key=value`, `key = value`, `key:value`, `key value`; last definition wins.
- **Security:** the write target comes from a committed file, so `_validate_external_target_path` locks it down — basename must be `gradle.properties`, must resolve inside `$HOME` (deepest-existing-ancestor resolved, symlink target/parent refused, `..` rejected). This blocks a malicious manifest from appending decrypted keys to `~/.gitconfig`/`~/.bashrc`. `_parse_secrets_files_manifest` rejects shell metacharacters/control chars in path and keys (path allows `[A-Za-z0-9/._~-]` only; keys allow `[A-Za-z0-9._-]` + space), mirrors the `.secrets-store` posture (no shell expansion, symlinked manifest skipped).
@ -138,43 +115,24 @@ Key design decisions (all driven by /autoplan review):
## Deploy Configuration
- Platform: NONE (distributed via `git clone` from the private Forgejo at `git.dev.egbt.com`)
- Platform: NONE (distributed via `git clone` from Codeberg)
- Production URL: N/A (no live service)
- Release model: merge to `main` is the release. Optionally tagged with `v<X.Y.Z.W>`.
- Verification after merge: a fresh `git clone` should produce a working `secrets which` against an isolated `$HOME`. No canary URL.
- Staging: none.
- Rollback: revert the merge commit on `main` (and delete the tag) to roll back.
## Forge operations (self-hosted Forgejo)
## Codeberg operations
The remote is a private Forgejo instance at `https://git.dev.egbt.com`
(migrated off Codeberg 2026-09-08). `gh`/`glab` do NOT work here. Use `tea`
(login name: `egbt`, user `brian`) for forge operations when a skill's
The remote is Codeberg (Forgejo) — `gh`/`glab` do NOT work here. Use `tea`
(login name: `codeberg`, user `egbt`) for forge operations when a skill's
platform detection comes up "unknown":
**Always pass `--login egbt --repo egbt/secrets` explicitly.** `tea`'s repo
autodetection fails here ("remote repository required"), and this machine also
has a leftover `codeberg` login pointing at the *old* forge
(`https://codeberg.org`) that `tea` will silently fall back to in
non-interactive mode — which would target the wrong server. Confirm with
`tea logins list` if a command errors.
- PRs: `tea pr create --login egbt --repo egbt/secrets --base main --head <branch> --title ... --description ...` / `tea pr merge <index> --login egbt --repo egbt/secrets`
- Releases: `tea releases create --login egbt --repo egbt/secrets --tag v<X.Y.Z.W> --title "v<X.Y.Z.W> — <YYYY-MM-DD>" --note ...`
- PRs: `tea pr create --base main --title ... --description ...` / `tea pr merge <index>`
- Releases: `tea releases create --tag v<X.Y.Z.W> --title "v<X.Y.Z.W> — <YYYY-MM-DD>" --note ...`
(convention: one release per tag, title `v<X.Y.Z.W> — <date>`)
- Issues/status: `tea issues --login egbt --repo egbt/secrets`, `tea pr list --login egbt --repo egbt/secrets`
- **SSH is on port 2222**, not 22 (port 22 is the host's own sshd). Clone/remote
URLs must be `ssh://git@git.dev.egbt.com:2222/egbt/secrets.git`. A bare
`git@git.dev.egbt.com:egbt/secrets.git` will fail with "Permission denied
(publickey)" because it hits the wrong daemon.
- The host resolves to a Tailscale address — the forge is reachable only on the
VPN. Off-net, push/pull/`tea` all fail to connect; that is expected, not a
credentials problem.
- `FORGEJO_URL` and `FORGEJO_TOKEN` (API token for user `brian`) live in
`~/.zshenv` for direct API calls.
- CI: the instance has an Actions runner available, but no workflow is
configured for this repo yet. The bats suite run locally is still the merge
gate.
- Issues/status: `tea issues`, `tea pr list`
- No CI on this repo: the bats suite run locally is the merge gate.
## Environment variable

247
README.md
View file

@ -58,97 +58,83 @@ Beyond project files, `secrets` can also sync files that live *outside* the proj
## Prerequisites
- **macOS or Linux**
- **git** (`git --version` to check)
- **age** and **jq**`install.sh` checks for these and prints the exact install command for your platform (Homebrew on macOS, `apt`/`dnf` on Linux)
- **macOS** (uses Homebrew for installation)
- **git** (already installed on most Macs — type `git --version` to check)
- **age** (the encryption tool — installed in step 1 below)
## Setup
Clone the tool repo, then run `install.sh`. It checks dependencies and prints the
two commands to finish setup. It never edits your shell config and never runs
sudo — it prints the commands so you stay in control.
### First machine (one-time setup)
```bash
git clone https://git.dev.egbt.com/egbt/secrets.git ~/dev/secrets
cd ~/dev/secrets
./install.sh
# 1. Install the encryption tool
brew install age
# 2. Download the secrets tool (this repo — contains only the CLI, no secret files)
git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
# 3. Make the 'secrets' command available everywhere
# Add this line to your shell config file (~/.zshrc on Mac):
export PATH="$HOME/dev/secrets:$PATH"
# Then restart your terminal, or run:
source ~/.zshrc
# 4. Initialize your encrypted secrets store
# This creates a folder at ~/.secrets/ with your encryption key
secrets init
# 5. Create a PRIVATE repository on GitHub to store your encrypted secrets
# Go to github.com/new, name it something like 'my-secrets', and make sure
# "Private" is selected. Then connect it:
cd ~/.secrets
git remote add origin git@github.com:<you>/my-secrets.git
git push -u origin main
```
`install.sh` prints a `export PATH="$HOME/dev/secrets:$PATH"` line — add it to your
shell config (`~/.zshrc` or `~/.bashrc`) and restart your terminal. Then onboard
this machine with one of the two flows below.
> **Important:** Step 5 creates a *separate* private repo for your encrypted secrets. This is different from the `secrets` tool repo you cloned in step 2. The tool repo can be public — it contains no secrets. The `~/.secrets/` repo must be private.
### First machine (new vault)
### Additional machines
On each new machine (your desktop, a teammate's laptop, etc.):
```bash
# 1. Create a PRIVATE repo for your encrypted secrets (github.com/new or a
# GitLab/Forgejo private repo). It holds only ciphertext — never your key.
# Then wire it up and push the store in one command:
secrets init --remote git@github.com:<you>/my-secrets.git
# 1. Install prerequisites and the tool (same as steps 1-3 above)
brew install age
git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
export PATH="$HOME/dev/secrets:$PATH" # add to ~/.zshrc
# 2. (optional) Add a project's secrets. From a project directory:
# 2. Clone the encrypted secrets repo
git clone git@github.com:<you>/my-secrets.git ~/.secrets
# 3. Copy the encryption key from your first machine
# This is the only step that requires direct machine-to-machine transfer.
# Choose one method:
#
# Option A: AirDrop (Mac to Mac)
# On your first machine, right-click ~/.secrets/key.txt → Share → AirDrop
# Save it to ~/.secrets/key.txt on the new machine
#
# Option B: Secure copy over SSH
# scp first-machine:~/.secrets/key.txt ~/.secrets/key.txt
#
# Option C: USB drive
# Copy key.txt to a USB drive, transfer it, delete from USB after
# 4. Pull your secrets into any project
cd ~/myapp
secrets push
# The first push asks once whether to auto-track new env files and records
# your choice in the project's .secrets.json.
secrets pull
```
`secrets init --remote` generates your key (`~/.secrets/key.txt`), wires the
remote, and pushes the initial store so the upstream branch exists. The private
secrets repo is separate from this tool repo — the tool repo is public and holds
no secrets; the `~/.secrets/` repo must be private.
> Running `secrets init` interactively (in a terminal) also offers to add your
> first project's secrets right away. Run it with `--yes` (or in any non-tty
> context like CI) to skip that prompt and just create the vault.
### Other machines (join an existing vault)
On a second machine, a desktop, or a teammate's laptop:
```bash
# 1. Clone the tool and run the bootstrap (as in Setup above)
git clone https://git.dev.egbt.com/egbt/secrets.git ~/dev/secrets
cd ~/dev/secrets && ./install.sh # add the printed PATH line to your shell config
# 2. Get key.txt onto this machine (the one manual, out-of-band step):
# AirDrop (Mac→Mac), or
# scp first-machine:~/.secrets/key.txt ~/Downloads/key.txt, or
# a USB drive (delete from the drive afterward)
# 3. Join the vault in one command:
secrets join --remote git@github.com:<you>/my-secrets.git --key ~/Downloads/key.txt
```
`secrets join` clones the vault, installs the key at mode 600, and **verifies the
key actually decrypts the store before declaring success** — a mis-copied key
fails loudly here, not silently on a later `secrets pull`. On success it tells you
to run `secrets pull` in any project.
> **The key file (`~/.secrets/key.txt`) is the only thing that needs to be transferred manually.** It never leaves your machines — it's excluded from git, never uploaded, never transmitted over the internet. Anyone with this file can decrypt all your secrets, so treat it like a password.
### Sharing with teammates
**Simple approach (shared key):** To share secrets with a teammate, they need:
To share secrets with a teammate, they need:
1. Access to your private secrets repo (add them as a collaborator)
2. A copy of `key.txt` (send it directly — AirDrop, USB, or in-person)
1. Access to your private `my-secrets` GitHub repo (add them as a collaborator)
2. A copy of `key.txt` (send it to them directly — AirDrop, USB, or in-person)
Everyone on the team uses the same key. A teammate joins with
`secrets join --remote <repo-url> --key <path-to-key.txt>`. When anyone runs
`secrets push`, the encrypted files update and everyone else runs `secrets pull`
to get the latest.
### Updating the tool
```bash
git -C ~/dev/secrets pull
```
If your store was last written by a newer client than yours, `secrets` prints a
one-line version-skew nudge — that's your cue to run the command above.
**Per-teammate keys (recommended for teams):** Use `secrets recipients add` so each person keeps their own private key — no key sharing needed. See [Onboarding and offboarding teammates](#onboarding-and-offboarding-teammates) below.
Everyone on the team uses the same key. When anyone runs `secrets push`, the encrypted files are updated and everyone else can `secrets pull` to get the latest version.
## Usage
@ -181,23 +167,13 @@ secrets clear
| `secrets clear` | Delete plaintext secret files from the current directory |
| `secrets run <command>` | Pull secrets, run a command, then clear secrets when it exits |
| `secrets list` | Show all projects that have stored secrets |
| `secrets list --json` | Same listing as a machine-readable JSON object (`{store, projects[].entries[]}`, each entry `dotenv`/`external`) for tooling and CI. JSON goes to stdout; notices to stderr |
| `secrets rm <project>` | Delete a project's secrets from the store |
| `secrets rekey` | Generate a new encryption key and re-encrypt everything (single-key store) or re-encrypt to the current recipients without changing keys (multi-recipient store) |
| `secrets rekey` | Generate a new encryption key and re-encrypt everything |
| `secrets verify [project]` | Check the current project's `.secrets.json` against the store (missing/orphaned blobs) and decrypt every blob. `[project]` overrides the store directory name; the manifest is still read from the current directory |
| `secrets verify --all` | Decrypt-test every blob in every project — a store-wide integrity sweep |
| `secrets migrate [--dry-run]` | Copy-forward this project's encrypted blobs to store format v2 (non-destructive; manifest-free; `--dry-run` previews) |
| `secrets migrate --status` | Survey every project's v2 readiness; exits non-zero until the whole store is finalize-ready |
| `secrets migrate --finalize` | **Optional GC** — drop the old v1 blobs and mark the store pure v2. Never required: upgraded clients dual-write and read-fall-back, so not finalizing never cuts anyone off |
| `secrets recipients list` | List the store's recipient public keys (and names if set) |
| `secrets recipients add <age1…> [--name N]` | Add a recipient key to the store and immediately re-encrypt every blob to the new set |
| `secrets recipients rm <key\|name> [--yes]` | Remove a recipient and re-encrypt the store; `--yes` required when removing your own key |
| `secrets reencrypt` | Re-encrypt every blob to the current recipients (idempotent — useful after a manual edit or partial failure) |
| `secrets sync` | Reconcile a store that has diverged from its remote: stash local blob edits, rebase onto the remote, restore the stash, then offer to publish your local commits. Never merges, force-pushes, or hard-resets |
| `secrets sync --dry-run` | Report the store's ahead/behind/dirty state and what a reconcile would do; changes nothing |
| `secrets sync --yes` | Reconcile and publish local commits without the confirmation prompt (for scripts) |
| `secrets upgrade` | Self-update the tool: `git pull --ff-only` on the `secrets` checkout, report old → new version, then re-check store version-skew. No auto-update, no background checks |
| `secrets upgrade --check` | Report whether an update is available (without pulling); changes nothing |
### Upgrading: do teammates on an older `secrets` get new secrets?
@ -214,32 +190,6 @@ Store-format v2 is **additive** — an upgraded client reads either blob suffix
And you'll be told when you're behind: if a store was last written by a newer `secrets` than the one you're running, any command prints a one-line nudge to stderr (non-fatal) — and `secrets which` shows the store's `written-by:` version. Stores written by older builds (no version stamp) stay silent.
### When the store diverges
The store is a git repo, so two machines pushing at once can leave your clone
both ahead and behind its remote. `secrets push` needs a fast-forward and
`secrets pull` won't silently merge, so both stop and tell you to run:
```bash
secrets sync
```
`sync` fetches, stashes any uncommitted blob edits, rebases your local commits
onto the remote, restores the stash, and then *asks* before publishing your
commits to the shared store (`--yes` skips the prompt; `--dry-run` just
reports). If the rebase conflicts, it aborts, restores your stash, names the
conflicting files, and leaves the store exactly as it found it — nothing in
the path force-pushes, hard-resets, or drops a stash.
`secrets which` now reports the same state up front, so you can see it coming:
```
store: /Users/you/.secrets
source: default
format: v2
remote: ahead 1, behind 11, 3 modified (run: secrets sync)
```
### Automatic project detection
When you run `secrets push` or `secrets pull` without specifying a project name, the tool figures out which project you're in by:
@ -425,10 +375,10 @@ git commit -am "switch to personal secrets"
### Monorepo support
For projects with multiple packages, add the `-w` flag to operate on all workspaces at once:
For projects with multiple packages (monorepos using `package.json` workspaces), add the `-w` flag to operate on all workspaces at once:
```bash
cd ~/myapp # npm/yarn workspaces, or a pnpm-workspace.yaml
cd ~/myapp # has package.json with "workspaces": ["apps/*", "packages/*"]
secrets push -w # encrypts secrets from root + each workspace
secrets pull -w # decrypts into root + each workspace directory
secrets clear -w # clears secrets from root + each workspace
@ -445,22 +395,7 @@ Inside `~/.secrets/`, workspace secrets are organized by path:
apps/api/.env.age # api workspace
```
**Where workspaces are declared.** All three package managers are supported:
| Manager | Declaration |
|---|---|
| npm | `package.json``"workspaces": ["apps/*"]` |
| yarn | `package.json``"workspaces": {"packages": ["apps/*"]}` |
| pnpm | `pnpm-workspace.yaml``packages:` block |
`package.json` wins when it declares any workspaces; `pnpm-workspace.yaml` is
the fallback. Reading `package.json` requires `jq` (`brew install jq`); a
pnpm-only repo needs no jq for workspace discovery.
If a root *looks* like a monorepo (a `pnpm-workspace.yaml` or a `packages/`
directory) but no workspace packages can be read from it, `secrets push` says
so on stderr rather than silently discovering nothing — that silence
previously made a pnpm repo indistinguishable from one with nothing to sync.
Requires `jq` (`brew install jq`).
### External files (Gradle properties)
@ -528,64 +463,6 @@ Some external secrets are whole binary files — an Android upload keystore, a c
On `secrets push` the file is encrypted into `<project>/external/`. On `secrets pull` it is restored to the same path with mode `600`; if a different version already exists there, it is backed up to `<name>.secrets-bak` first. The same path rules apply (inside `$HOME`, no `..`, no symlinks). Like merged Gradle keys, restored files are permanent plaintext on disk — `secrets clear` does not remove them.
### Onboarding and offboarding teammates
By default every team member uses the **same** `key.txt` (one shared private key). The multi-recipient feature lets each teammate have their **own** keypair while still sharing one store — so you never hand out a secret key to a new hire, and removing an ex-teammate's access is one command.
#### Onboarding a teammate
```bash
# 1. Teammate generates their own keypair on their machine (never shares the private key)
age-keygen -o ~/.secrets/key.txt # writes key.txt; prints the public key
# 2. Teammate sends you their PUBLIC key (printed by age-keygen, starts with age1…)
# — over Slack, email, whatever. Public keys are not secret.
# 3. An existing member adds the public key to the store
secrets recipients add age1theirpublickey --name alice
# => Adds alice to recipients.txt, re-encrypts every blob to the full set, pushes.
# 4. Teammate clones the store repo and drops their key.txt in place
git clone git@github.com:<you>/my-secrets.git ~/.secrets
# (key.txt already generated in step 1 — nothing to copy)
# 5. Teammate pulls into any project
cd ~/myapp
secrets pull
# => Their key matches one recipient stanza in every blob — it just works.
```
Run `secrets recipients list` to confirm who has access:
```
alice age1theirpublickey…
you age1yourpublickey…
```
#### Offboarding a teammate
```bash
# Remove the recipient by name (or public key) and re-encrypt the store
secrets recipients rm alice
# => Removes alice from recipients.txt, re-encrypts every blob, pushes.
# Existing blobs are re-encrypted; the removed key can no longer decrypt them.
```
> **Important:** git history can't be un-shared. If alice had access during a period when genuinely sensitive values were stored, rotate those values now (update them in the external system and run `secrets push`). The re-encrypt prevents future access; history is permanent.
#### Managing recipients
```bash
secrets recipients list # show all recipient keys and names
secrets recipients add age1… # add a key (bootstraps recipients.txt on a legacy store)
secrets recipients add age1… --name bob # attach a human-readable label
secrets recipients rm bob # remove by name
secrets recipients rm age1… # remove by public key
secrets reencrypt # re-encrypt to current recipients (idempotent heal)
```
`secrets which` shows a `recipients: N (alice, bob, …)` line so you can always confirm the active set from any project directory.
## Safety features
- **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C
@ -639,7 +516,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/`
## Development
```bash
# Run the test suite (272 tests across four files)
# Run the test suite (237 tests across three files)
brew install bats-core
bats test/

View file

@ -1 +1 @@
0.7.7.0
0.7.1.0

View file

@ -1,230 +0,0 @@
# EGB-283 — Multi-recipient age encryption
**Date:** 2026-06-24
**Issue:** [EGB-283](https://linear.app/egbt/issue/EGB-283) — secrets: multi-recipient age encryption (multiple keys per file)
**Status:** Design approved, ready for implementation plan
**Related:** EGB-281 (multi-store), EGB-677/EGB-703 (manifest + store-format v2)
## Problem
Today every blob in a store is encrypted to exactly one age public key (`age -r "$pubkey"`,
where `$pubkey` is derived from the store's single `key.txt`). The whole team shares one
private key. EGB-281's multi-store work lets users separate work/personal/client secrets into
different stores, each with its own key — but *within* a single store there is still only one key,
so onboarding/offboarding a teammate means sharing or rotating one secret by hand.
age natively supports multiple recipients: `age -r KEY1 -r KEY2 -o file.age input` writes one
recipient stanza per key, and any matching identity decrypts. This lets a single store have N
members, each with their own keypair. Adding/removing a teammate becomes a re-encrypt against the
current recipient set — no shared password.
## Goals
- A store can encrypt every blob to N recipient public keys.
- Recipient set is managed with first-class commands (`secrets recipients add/rm/list`).
- The recipient set is **singular and consistent per store**: every blob is always readable by
exactly the current set.
- Fully backward compatible: existing single-key stores keep working untouched; the feature is
opt-in and detected by file presence (no store-format-marker bump).
- Decryption path is unchanged (members use their own `key.txt`).
## Non-goals (YAGNI — explicit scope cuts)
- **SSH recipients** (`ssh-ed25519` / `ssh-rsa`). Native age X25519 keys cover the team-key use
case; SSH adds a parsing/format axis. Clean future follow-up.
- **Per-file or per-project recipient subsets.** The whole store shares one recipient set.
- **Key discovery / distribution.** Public keys are pasted in out of band, exactly as `key.txt`
is shared today.
- **Merging recipients into a project-level config** (`.secrets.json` / `.secrets-files`). See
"Why recipients are not in the project manifest" below.
## Design decisions (resolved during brainstorming)
1. **Storage:** committed `recipients.txt` at the store root, managed via
`secrets recipients add/rm/list` subcommands.
2. **Re-encrypt scope:** `add`/`rm` re-encrypt the **entire store immediately** to the new set in
one commit. The store is always consistent.
3. **Backward compatibility:** absence of `recipients.txt` ⇒ exact current single-key behavior.
First `recipients add` on a legacy store bootstraps the file seeded with the local pubkey plus
the new key. `init` going forward seeds `recipients.txt` with the freshly generated pubkey
(born-multi).
4. **`rekey` semantics:** on a multi-recipient store, `rekey` becomes "re-encrypt all to the
current `recipients.txt` set" (no new keypair). On a legacy store it keeps today's behavior
(generate a new keypair, re-encrypt to it). One shared re-encrypt engine.
5. **Store config shape:** keep `recipients.txt` as its own plain, age-native file (jq-free),
alongside the existing one-line `.secrets-format` marker — matching the repo's
small-single-purpose-plain-file convention. Not folded into a JSON store-config.
## Why recipients are not in the project manifest
The tool has two config planes in two different git repos:
| Plane | Location | Files | Scope |
| ----------- | -------------------------------- | -------------------------------------------------- | --------------------------- |
| **Project** | `$PWD` (the project's own repo) | `.secrets.json` (absorbs legacy `.secrets-files`), `.secrets-store` | *What this project syncs* |
| **Store** | `$SECRETS_DIR` (`~/.secrets`) | `.secrets-format`, **`recipients.txt`** (new) | *Metadata about the encrypted repo* |
Recipients are **store-scoped** — who can decrypt *this store*, shared by every project in it.
Putting them in a project-level manifest would let each project carry its own copy and **diverge**,
the exact inconsistency the "always re-encrypt the whole store to one set" rule prevents. It also
collides with the deliberate EGB-703 decision that *the store holds no project manifest*. So the
recipient set lives with the store, next to `.secrets-format`.
## `recipients.txt` format and security rails
Lives at `$SECRETS_DIR/recipients.txt`, **committed** (public keys are not secret; the store
`.gitignore` only blocks `key.txt` and plaintext env files, so the file is tracked automatically).
age `-R` format: one recipient per line, `# comment` and blank lines allowed.
```
# alice (laptop)
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9qsxxxxxx
# bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqxxxxxx
```
**We do not pass the file path to `age -R`.** A committed file is an injection surface, so the
tool parses it itself into a validated indexed array `RECIPIENT_ARGS=(-r age1… -r age1…)`,
mirroring the conservative posture of `.secrets-store` / `.secrets-files`:
- Each non-comment, non-blank line (after trim) must match a native age X25519 recipient exactly:
`^age1[0-9a-z]{58}$`. Anything else — SSH recipients, shell metacharacters, whitespace inside the
token, control/ANSI characters, `-r`-injection look-alikes — is **rejected with a clear error**.
No shell expansion, ever.
- A symlinked `recipients.txt` is refused (same rail as the manifests).
- `--name` labels (written as `# <name>` comment lines above the key) are restricted to
`[A-Za-z0-9 ._-]`; anything else is rejected. This blocks comment-injection into the file.
- The parser is pure bash (bash-3.2 safe). Indexed arrays are fine on bash 3.2; only *associative*
arrays are bash-4.
Validation is the single source of truth — both the `recipients` subcommands and `_load_recipients`
(below) route through the same validator, so an externally hand-edited malicious file is caught on
the next encrypt, not just at `add` time.
## Components
### `_load_recipients()` — populate `RECIPIENT_ARGS`
Called once per command that encrypts. Populates the global indexed array `RECIPIENT_ARGS`:
- `recipients.txt` present → validated array of every key in the file (error out on any invalid
line; refuse an empty/all-comment file).
- absent (legacy store) → `(-r <derived-local-pubkey>)`, identical to today's single-recipient
behavior.
### `_reencrypt_all()` — shared re-encrypt engine
Factored out of today's `cmd_rekey` decrypt→re-encrypt loop:
1. Decrypt every `*.age` in the store (recursive `find -type f -name '*.age'`, covering nested
manifest dotenv blobs and `external/` blobs) with the local `key.txt` into a tmpdir. The
operator must be a current recipient; a decryption failure aborts with the old state preserved.
2. Re-encrypt each file with `age "${RECIPIENT_ARGS[@]}"` back to its relpath.
3. `ensure_store_protections`, `git add -A`, commit, push (if a remote exists).
All recipient-changing paths call it:
| Command | Behavior |
| ------------------------------- | -------------------------------------------------------------------- |
| `recipients add` / `rm` | edit `recipients.txt``_load_recipients``_reencrypt_all` |
| `rekey` (multi-recipient store) | `_reencrypt_all` to current set, **no new keypair** |
| `rekey` (legacy store) | today's behavior: generate new keypair, set recipients to it, re-encrypt |
| `reencrypt` (new, idempotent) | `_reencrypt_all` — heal/backfill after a manual edit |
### `secrets recipients` subcommand
- `recipients list` — prints names + keys from `recipients.txt` (read-only, jq-free). On a legacy
store, prints the single derived pubkey with a "single-key (no recipients.txt)" note.
- `recipients add <age1…> [--name <label>]` — validates the key, refuses duplicates, appends
(with the optional `# <label>` comment), bootstraps the file with `{local pubkey, new key}` if
the store is still legacy, then `_reencrypt_all`.
- `recipients rm <age1…|name>` — removes the matching entry, then `_reencrypt_all`. **Guards:**
refuses to remove the last recipient; removing *your own* key (which would lock you out of future
pulls) requires `--yes`. Removal takes effect going forward — git history can't be un-shared
(documented, same caveat as today's `rekey`).
### Encryption call-site changes
Every existing `age -r "$pubkey" -o …` site becomes `age "${RECIPIENT_ARGS[@]}" -o …`:
- `push_dir_to_project` (dotenv) and the nested manifest dotenv path
- `push_external_files` (external `properties` and `file` blobs)
- `cmd_init` (seeds the store; born-multi)
- `cmd_rekey` re-encrypt loop (now `_reencrypt_all`)
Functions that currently take `pubkey` as a positional arg are updated to rely on the
`RECIPIENT_ARGS` global populated by `_load_recipients` at command entry, avoiding array-passing
gymnastics on bash 3.2. **Decryption paths (`pull`, `verify`) are unchanged** — `age -d -i
"$KEY_FILE"` already tries the identity against all recipient stanzas.
### `init` / `which` / `verify` integration
- **`init`** writes `recipients.txt` seeded with the freshly generated pubkey (born-multi), staged
like `.secrets-format`.
- **`which`** prints a `recipients: N (alice, bob, …)` line, or `recipients: single-key (no
recipients.txt)` for a legacy store.
- **`verify`** already decrypt-tests with the local key — works as-is for a member. **Added cheap
invariant:** count the `-> X25519` recipient stanzas in each blob header and assert it equals the
number of entries in `recipients.txt`. age exposes no way to list *which* recipients a file
targets (X25519 stanzas are ephemeral), so a count check is the strongest machine-checkable
invariant; full-identity consistency relies on the always-re-encrypt-all rule. The check is
skipped on legacy stores (no `recipients.txt`).
## Data flow
**Onboarding a teammate**
1. Teammate runs `age-keygen` locally, sends their **public** key out of band.
2. An existing member: `secrets recipients add age1theirpub --name them` → store re-encrypts to
`{existing…, them}` in one commit, pushed.
3. Teammate clones the store repo, drops their own `key.txt` in place, and `secrets pull` works —
their key matches one stanza in every blob.
**Offboarding**
1. `secrets recipients rm them` → store re-encrypts to the remaining set, pushed. New blobs are no
longer readable by the removed key. (Historical git revisions remain readable by their old key —
rotate any still-sensitive secret values, same as today.)
## Error handling
- Invalid/duplicate key on `add` → reject before any re-encrypt; store untouched.
- Invalid line discovered by `_load_recipients` during any encrypt → abort the command with a clear
pointer to the offending line; nothing written.
- `_reencrypt_all` decryption failure (operator not a current recipient, or corrupt blob) → abort,
old store state preserved (mirrors today's `rekey` safety).
- `rm` last recipient → refused. `rm` own key → requires `--yes`.
- Empty/all-comment `recipients.txt` → treated as an error (a store with zero recipients can encrypt
nothing).
## Testing
New `test/recipients.bats` suite plus additions to existing suites:
- **Happy path:** `init` born-multi; `add` bootstraps a legacy store; `add`/`rm` round-trip; a blob
encrypted to 3 keys decrypts with each of the 3 identities; `list` output; `rekey` on a
multi-recipient store keeps the set and generates no new key; `reencrypt` is idempotent.
- **Backward compat:** a legacy store (no `recipients.txt`) still pushes/pulls/rekeys exactly as
before; an old single-key client decrypts a recipients.txt-store where its key is a recipient.
- **`verify`:** stanza-count invariant passes on a healthy multi-recipient store and flags a blob
whose recipient count drifted.
- **Security regression fixtures** (`recipients.txt` is a new committed attack surface): non-age /
malformed keys, shell metacharacters, control/ANSI characters, `-r`-injection look-alikes,
symlinked `recipients.txt`, bad `--name`, remove-last-recipient, remove-self-without-`--yes`.
Per the repo security-review policy (`.ship-policy.json`, CLAUDE.md): these adversarial fixtures are
written as **ordinary bats regression tests**, not AI red-team/adversarial-review passes. Before any
ship/PR, the human operator runs `./test/run-security.sh` and completes the SIGNOFF prompt;
`./test/run-security.sh` is operator-local and is **not** run on the user's behalf.
## Backward compatibility / migration
No store-format-marker bump. Multi-recipient is purely additive and detected by the **presence** of
`recipients.txt`, mirroring the `.secrets-format` "absence implies v1" pattern. Existing stores keep
working with zero action; a store becomes multi-recipient the first time `recipients add` (or `init`
on a fresh store) writes `recipients.txt`.
## Documentation
- `CLAUDE.md` Architecture section: add a multi-recipient bullet (store-scoped `recipients.txt`,
`recipients` subcommand, shared `_reencrypt_all` engine, `rekey` dual semantics, security rails).
- `README.md`: onboarding/offboarding a teammate; `recipients add/rm/list`.
- Test counts in `CLAUDE.md` Project Structure updated.
## Open questions
None blocking. (SSH-recipient support and a JSON store-config remain possible future follow-ups,
explicitly out of scope here.)

View file

@ -1,113 +0,0 @@
#!/usr/bin/env bash
#
# secrets — thin onboarding bootstrap (EGB-671).
#
# This script ships INSIDE the repo: you already cloned the repo to get it, so
# its only jobs are (1) verify the dependencies the tool needs and (2) print the
# exact commands to finish setup. It deliberately does NOT:
# - edit your shell rc files (it prints the PATH line for you to paste)
# - invoke sudo or install packages behind your back (it prints the command)
# - re-implement any of the tool's security logic
#
# This is a security tool whose whole pitch is "verify, don't trust" — so the
# installer holds itself to a higher bar than convenience, not a lower one.
#
# Usage:
# ./install.sh # check deps, print setup + next steps
# ./install.sh --help
set -euo pipefail
# Resolve the directory this script lives in (the cloned tool repo). Uses bash
# builtins only so it works under a minimal PATH.
_src="${BASH_SOURCE[0]}"
TOOL_DIR="$(cd "${_src%/*}" 2>/dev/null && pwd)"
usage() {
cat <<EOF
install.sh — finish setting up the 'secrets' tool.
Run this once after cloning the repo. It verifies dependencies (age, jq, git)
and prints the commands to put 'secrets' on your PATH and onboard a machine.
Usage:
./install.sh Check dependencies and print setup + next steps
./install.sh --help Show this help
It never edits your shell config and never runs sudo — it prints the exact
commands so you stay in control (this is a secrets tool, after all).
Onboarding after setup:
First machine: secrets init --remote <your-private-repo-url>
Other machine: secrets join --remote <your-private-repo-url> --key <key.txt>
EOF
}
# Print the install command for a package, using whatever package manager is
# present. For sudo-requiring managers we PRINT the line for you to run — the
# installer never escalates on its own.
install_hint() {
local pkg="$1"
if command -v brew >/dev/null 2>&1; then
echo "brew install $pkg"
elif command -v apt-get >/dev/null 2>&1; then
echo "sudo apt-get install -y $pkg"
elif command -v dnf >/dev/null 2>&1; then
echo "sudo dnf install -y $pkg"
else
echo "install '$pkg' with your system package manager"
fi
}
case "${1:-}" in
--help|-h) usage; exit 0 ;;
"") ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
echo "secrets — bootstrap check (tool dir: $TOOL_DIR)"
echo ""
# Dependency check. age + jq + git are all load-bearing on the cold-start path:
# jq became required once .secrets.json (manifest) is JSON, so it must be present
# BEFORE the first manifest read.
missing=0
for dep in git age jq; do
if command -v "$dep" >/dev/null 2>&1; then
echo " ok $dep"
else
echo " MISSING $dep — install it with:"
echo " $(install_hint "$dep")"
missing=1
fi
done
echo ""
if [ "$missing" -ne 0 ]; then
echo "Install the missing dependencies above, then re-run ./install.sh." >&2
exit 1
fi
cat <<EOF
All dependencies present. Two steps to finish:
1) Put 'secrets' on your PATH. Add this line to your shell config
(~/.zshrc or ~/.bashrc), then restart your terminal:
export PATH="$TOOL_DIR:\$PATH"
2) Onboard this machine:
First machine (new vault):
secrets init --remote <your-private-repo-url>
# then transfer key.txt to your other machines (AirDrop / scp / USB):
# scp <this-host>:$HOME/.secrets/key.txt ~/.secrets/key.txt
Other machine (join an existing vault):
secrets join --remote <your-private-repo-url> --key <path-to-key.txt>
# 'join' clones the vault, installs the key, and VERIFIES it decrypts
# before declaring success — a mis-copied key fails loudly, not silently.
To update the tool later:
git -C "$TOOL_DIR" pull
EOF

1300
secrets

File diff suppressed because it is too large Load diff

View file

@ -1,72 +0,0 @@
#!/usr/bin/env bats
# EGB-671: install.sh thin bootstrap. It ships IN the repo (you clone the repo
# to get it), so its job is: verify deps (age + jq + git), PRINT the PATH line
# and next-step commands — never edit dotfiles, never invoke sudo. Security-rail
# concerns are operator-local (.ship-policy.json); these are functional checks.
load test_helper
INSTALL_SH="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." && pwd)/install.sh"
@test "install.sh exists and is executable" {
[ -f "$INSTALL_SH" ]
[ -x "$INSTALL_SH" ]
}
@test "install.sh --help prints usage and exits 0" {
run "$INSTALL_SH" --help
[ "$status" -eq 0 ]
[[ "$output" == *"install.sh"* ]] || false
[[ "$output" == *"join"* ]] || false
}
@test "install.sh prints the PATH export line for the tool dir (does not edit rc)" {
local tool_dir
tool_dir="$(cd "$(dirname "$INSTALL_SH")" && pwd)"
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"export PATH="* ]] || false
[[ "$output" == *"$tool_dir"* ]] || false
# It must NOT have written to any shell rc in the isolated HOME.
[ ! -f "$HOME/.zshrc" ]
[ ! -f "$HOME/.bashrc" ]
}
@test "install.sh prints both onboarding next-steps (init --remote and join)" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"secrets init --remote"* ]] || false
[[ "$output" == *"secrets join --remote"* ]] || false
}
@test "install.sh prints the upgrade one-liner" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"git -C"* ]] || false
[[ "$output" == *"pull"* ]] || false
}
@test "install.sh prints a key-transfer hint" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"key.txt"* ]] || false
}
@test "install.sh never invokes sudo (prints it for the user instead)" {
# No executed 'sudo' — any sudo reference must be quoted guidance text.
run grep -nE '^[[:space:]]*sudo ' "$INSTALL_SH"
[ "$status" -ne 0 ]
}
@test "install.sh reports a missing dependency with an install hint and non-zero exit" {
# Build a minimal PATH that has the tools install.sh needs but NOT jq.
local fake="$TEST_TMPDIR/fakebin"
mkdir -p "$fake"
for t in bash uname env cat grep sed tr dirname command age git printf; do
src="$(command -v "$t" 2>/dev/null || true)"
[ -n "$src" ] && ln -sf "$src" "$fake/$t" 2>/dev/null || true
done
run env PATH="$fake" "$INSTALL_SH"
[ "$status" -ne 0 ]
[[ "$output" == *"jq"* ]] || false
}

View file

@ -1,145 +0,0 @@
#!/usr/bin/env bats
# EGB-671: `secrets join` (second-machine onboarding) + `secrets init --remote`
# + day-2 silent-decrypt fix. Functional paths only — security-rail tests
# (path traversal on --key/--store, URL injection) are operator-local per
# .ship-policy.json and live in test/run-security.sh.
load test_helper
# Push a project to REMOTE_DIR and save the key, then remove the local store
# to simulate a fresh second machine. Leaves: REMOTE_DIR has blobs,
# $TEST_TMPDIR/saved-key.txt is the decrypting key, $SECRETS_DIR is gone.
_machine1_push_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
create_project_dir "joinproj"
"$SECRETS_BIN" push >/dev/null 2>&1
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# Like above but never pushes a project — remote has a store with zero blobs.
_machine1_empty_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# ─── secrets join ────────────────────────────────────────────────────────
@test "join without --remote fails with usage" {
run "$SECRETS_BIN" join
[ "$status" -ne 0 ]
[[ "$output" == *"--remote"* ]] || false
}
@test "join clones the store, installs the key at 600, verifies, and succeeds" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"VERIFIED"* ]] || false
[ -d "$SECRETS_DIR/.git" ]
[ -f "$SECRETS_DIR/key.txt" ]
# key installed at mode 600
local perms
perms=$(stat -f '%Lp' "$SECRETS_DIR/key.txt" 2>/dev/null || stat -c '%a' "$SECRETS_DIR/key.txt")
[ "$perms" = "600" ]
}
@test "join with the wrong key fails loudly and does not report VERIFIED" {
_machine1_push_then_wipe
age-keygen -o "$TEST_TMPDIR/wrong-key.txt" 2>/dev/null
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/wrong-key.txt"
[ "$status" -ne 0 ]
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join against an empty store reports nothing-to-verify, NOT VERIFIED" {
_machine1_empty_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"nothing to verify"* ]] || false
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join refuses when a store already exists at the target" {
"$SECRETS_BIN" init >/dev/null 2>&1
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"already"* ]] || false
}
@test "join fails clearly when the key file is missing" {
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/nope.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
@test "join detects a directory passed as --key" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
# ─── secrets init --remote ────────────────────────────────────────────────
@test "init --remote sets origin and establishes an upstream branch" {
run "$SECRETS_BIN" init --remote "$REMOTE_DIR"
[ "$status" -eq 0 ]
run git -C "$SECRETS_DIR" remote get-url origin
[ "$status" -eq 0 ]
[ "$output" = "$REMOTE_DIR" ]
# upstream branch exists on the remote (so a later push won't ff-only die)
run git -C "$SECRETS_DIR" rev-parse --abbrev-ref '@{u}'
[ "$status" -eq 0 ]
}
@test "init --remote then push does not die on the brand-new remote" {
"$SECRETS_BIN" init --remote "$REMOTE_DIR" >/dev/null 2>&1
create_project_dir "freshproj"
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" != *"Fast-forward pull failed"* ]] || false
}
@test "init with no flags still works (clean primitive)" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/key.txt" ]
}
@test "init does not hang on the first-add prompt when stdin is a tty but stdout is captured" {
# Regression: run-security.sh runs bats in a real terminal, so the command's
# stdin stays a tty while bats captures its stdout. The interactive first-add
# prompt must NOT fire in that shape (it gates on stdout being a tty too),
# or the whole suite hangs. Reproduce with a pty via `script`.
command -v script >/dev/null 2>&1 || skip "script (pty) not available"
# macOS/BSD syntax: `script -q <file> <cmd...>`. Skip on other syntaxes.
script -q /dev/null true >/dev/null 2>&1 || skip "unsupported script syntax"
local out="$TEST_TMPDIR/pty-initout"
run timeout 10 script -q /dev/null bash -c "'$SECRETS_BIN' init > '$out' 2>&1"
[ "$status" -ne 124 ] # 124 == timeout == it hung on a prompt
run grep -c "Add a project's secrets" "$out"
[ "$output" = "0" ]
}
# ─── day-2 silent-decrypt fix ─────────────────────────────────────────────
@test "pull dies loudly when a blob cannot be decrypted with the current key" {
init_with_remote
create_project_dir "decryptproj"
"$SECRETS_BIN" push >/dev/null 2>&1
# Swap in a different key so the stored blob no longer decrypts.
# (age-keygen refuses to overwrite, so generate elsewhere then copy.)
age-keygen -o "$TEST_TMPDIR/other-key.txt" 2>/dev/null
cp "$TEST_TMPDIR/other-key.txt" "$SECRETS_DIR/key.txt"
chmod 600 "$SECRETS_DIR/key.txt"
cd "$WORK_DIR/decryptproj"
rm -f .env .env.staging
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
[[ "$output" == *"decrypt"* ]] || false
}

View file

@ -167,17 +167,6 @@ load test_helper
[ "$output" = "2" ]
}
@test "bootstrap: first push writes an explicit options.autoAdd value (EGB-677 contract #2)" {
init_with_remote
create_project_dir autoaddproj
# Non-interactive (bats has no tty): the prompt is skipped and the tool
# default (ON) is written explicitly so the value is committed + team-shared.
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
run jq -r '.options.autoAdd' .secrets.json
[ "$output" = "true" ]
}
@test "failed push leaves no bootstrap manifest behind" {
init_with_remote
mkdir -p "$WORK_DIR/emptyproj"
@ -642,31 +631,6 @@ m_nojq_path() {
[[ "$output" == *"k1"* ]] || false
}
# EGB-701 item 1: `which` and the push/pull external extractor share one
# helper (_json_external_entries), so `which` applies the same
# properties→gradle-properties normalization the sync path uses — no drift.
@test "which normalizes a properties external to the gradle-properties token (EGB-701)" {
create_project_dir whichnorm
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["k1"]}]}\n' > .secrets.json
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"gradle-properties"* ]] || false
}
# EGB-701 item 1: a malformed external (a properties entry with no keys) is
# skipped by the sync path; routing `which` through the shared extractor means
# `which` skips+warns it too, so it faithfully shows what actually syncs
# rather than printing an entry push/pull silently drop.
@test "which skips a malformed external entry the sync path would drop (EGB-701)" {
create_project_dir whichmalformed
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties"}]}\n' > .secrets.json
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"has no keys"* ]] || false
# The skipped entry's path must NOT appear in the printed manifest summary.
[[ "$output" != *" gradle-properties ~/.gradle/gradle.properties"* ]] || false
}
# ─── F: ship Step 7 coverage backfill (audit gaps) ─────────────────────
@test "which flags an unsafe dotenv entry with the UNSAFE marker" {
@ -764,46 +728,6 @@ m_nojq_path() {
[ "$(cat packages/web/.env.development)" = "N=nested" ]
}
@test "legacy (manifest-less) pull warns about nested blobs it can't restore (EGB-701)" {
# The legacy pull path globs only top-level *.age/.*.age. A nested dotenv
# blob (<project>/<relpath>.age) written by a manifest-driven push on another
# machine is invisible to those globs — restored nothing, counted nothing.
# The fix: warn so a manifest-less pull never silently under-restores.
init_with_remote
create_project_dir nestlegacy
mkdir -p packages/web
echo "N=nested" > packages/web/.env.development
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
"$SECRETS_BIN" push >/dev/null 2>&1
[ -f "$SECRETS_DIR/nestlegacy/packages/web/.env.development.age" ]
# Simulate a machine with no manifest: drop .secrets.json + local files,
# forcing the legacy non-recursive glob branch.
rm -f .secrets.json
rm -rf packages
run "$SECRETS_BIN" pull nestlegacy
[ "$status" -eq 0 ]
# The warning names the nested blob and points at the manifest as the fix.
[[ "$output" == *"packages/web/.env.development"* ]] || false
[[ "$output" == *"$SECRETS_JSON_NAME"* || "$output" == *".secrets.json"* ]] || false
# The legacy path genuinely can't restore it (the warning is the contract).
[ ! -f packages/web/.env.development ]
}
@test "legacy pull does NOT warn about external/ blobs (handled separately, EGB-701)" {
# external/<slug>.age blobs are restored by pull_external_files, not the
# dotenv globs, so they must not trip the nested-blob warning.
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir extnolwarn
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push >/dev/null 2>&1
[ -d "$SECRETS_DIR/extnolwarn/external" ]
run "$SECRETS_BIN" pull extnolwarn
[ "$status" -eq 0 ]
[[ "$output" != *"can't restore"* ]] || false
[[ "$output" != *"nested encrypted"* ]] || false
}
@test "list shows a nested manifest blob" {
init_with_remote
create_project_dir nestlist

View file

@ -1,407 +0,0 @@
#!/usr/bin/env bats
load test_helper
# A throwaway second identity for "another teammate".
make_second_identity() {
age-keygen -o "$TEST_TMPDIR/bob.txt" 2>/dev/null
BOB_PUB=$(age-keygen -y "$TEST_TMPDIR/bob.txt")
}
@test "push with only-self recipients.txt encrypts to the store key (born-multi)" {
init_with_remote
# init now seeds recipients.txt with self — born-multi store.
[ -e "$SECRETS_DIR/recipients.txt" ]
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
# Blob must still decrypt with the store's own key.
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "push with a hand-written recipients.txt encrypts to every listed key" {
init_with_remote
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '# self\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
# Bob (a recipient) can decrypt the pushed blob with HIS key.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
# And the store key still can too.
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "push refuses a recipients.txt with an invalid key" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\nnot-an-age-key\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
}
@test "push refuses a symlinked recipients.txt" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
# Remove the born-multi recipients.txt so we can replace it with a symlink.
rm -f "$SECRETS_DIR/recipients.txt"
ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"symlink"* ]] || false
}
@test "recipients list on a legacy store shows the single derived key" {
init_with_remote
# Simulate a legacy store by removing the born-multi recipients.txt.
rm -f "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" recipients list
[ "$status" -eq 0 ]
[[ "$output" == *"single-key"* ]] || false
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
[[ "$output" == *"$STORE_PUB"* ]] || false
}
@test "recipients list shows names and keys from recipients.txt" {
init_with_remote
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '# alice\n%s\n# bob\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" recipients list
[ "$status" -eq 0 ]
[[ "$output" == *"recipients: 2"* ]] || false
[[ "$output" == *"alice"* ]] || false
[[ "$output" == *"bob"* ]] || false
}
@test "reencrypt re-encrypts existing blobs to a newly added recipient line" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push # single-key blob (project name = "myproj"; blob at $SECRETS_DIR/myproj/.env.age)
[ "$status" -eq 0 ]
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
# Bob cannot read the old single-key blob yet.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -ne 0 ]
run "$SECRETS_BIN" reencrypt
[ "$status" -eq 0 ]
# Now he can.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "rekey on a multi-recipient store keeps recipients and the same key" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
before=$(cat "$SECRETS_DIR/key.txt")
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" rekey
[ "$status" -eq 0 ]
# No new keypair was generated.
[ "$(cat "$SECRETS_DIR/key.txt")" = "$before" ]
# Both recipients can decrypt.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "rekey on a legacy store still rotates to a new key (unchanged)" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
before=$(cat "$SECRETS_DIR/key.txt")
# Simulate a legacy store by removing the born-multi recipients.txt.
rm -f "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" rekey
[ "$status" -eq 0 ]
[ "$(cat "$SECRETS_DIR/key.txt")" != "$before" ]
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "recipients add bootstraps a legacy store and re-encrypts" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
# Simulate a legacy store (no recipients.txt) so recipients add triggers the
# bootstrap branch (if [ ! -e "$RECIPIENTS_FILE" ]) rather than the append path.
rm -f "$SECRETS_DIR/recipients.txt"
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
[ "$status" -eq 0 ]
[ -e "$SECRETS_DIR/recipients.txt" ]
# recipients.txt now has self + bob (2 keys).
run "$SECRETS_BIN" recipients list
[[ "$output" == *"recipients: 2"* ]] || false
[[ "$output" == *"bob"* ]] || false
# Existing blob re-encrypted: bob can read it.
run age -d -i "$TEST_TMPDIR/bob.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "recipients add rejects a non-age key" {
init_with_remote
run "$SECRETS_BIN" recipients add "ssh-ed25519 AAAAfoo"
[ "$status" -ne 0 ]
[[ "$output" == *"valid age recipient"* ]] || false
}
@test "recipients add rejects a duplicate" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
[ "$status" -eq 0 ]
run "$SECRETS_BIN" recipients add "$BOB_PUB"
[ "$status" -ne 0 ]
[[ "$output" == *"already present"* ]] || false
}
@test "recipients add rejects an unsafe --name" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name 'bob; rm -rf ~'
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid --name"* ]] || false
}
@test "recipients add rejects a whitespace-only --name" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name ' '
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid --name"* ]] || false
}
@test "recipients add --name with no value errors" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name
[ "$status" -ne 0 ]
[[ "$output" == *"--name requires a value"* ]] || false
}
@test "recipients rm removes a recipient and re-encrypts to the rest" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
run "$SECRETS_BIN" recipients rm bob
[ "$status" -eq 0 ]
run "$SECRETS_BIN" recipients list
[[ "$output" == *"recipients: 1"* ]] || false
# Store key still reads its own blobs.
run age -d -i "$SECRETS_DIR/key.txt" "$SECRETS_DIR/myproj/.env.age"
[ "$status" -eq 0 ]
}
@test "recipients rm refuses to remove the last recipient" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # store = self + bob
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
run "$SECRETS_BIN" recipients rm bob # back to self only
[ "$status" -eq 0 ]
run "$SECRETS_BIN" recipients rm "$STORE_PUB" # would be the last
[ "$status" -ne 0 ]
[[ "$output" == *"last recipient"* ]] || false
}
@test "recipients rm of your own key requires --yes" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
run "$SECRETS_BIN" recipients rm "$STORE_PUB"
[ "$status" -ne 0 ]
[[ "$output" == *"your own key"* ]] || false
run "$SECRETS_BIN" recipients rm "$STORE_PUB" --yes
[ "$status" -eq 0 ]
}
@test "recipients rm of a non-existent target errors" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
run "$SECRETS_BIN" recipients rm carol
[ "$status" -ne 0 ]
[[ "$output" == *"No recipient matches"* ]] || false
}
@test "init seeds recipients.txt with the new store key (born-multi)" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -e "$SECRETS_DIR/recipients.txt" ]
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
run cat "$SECRETS_DIR/recipients.txt"
[[ "$output" == *"$STORE_PUB"* ]] || false
}
@test "which reports the recipient count" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
create_project_dir myproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"recipients: 2"* ]] || false
[[ "$output" == *"bob"* ]] || false
}
@test "which reports single-key for a legacy store" {
init_with_remote
rm -f "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"recipients: single-key"* ]] || false
}
@test "verify --all passes on a healthy multi-recipient store" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # re-encrypts to 2
run "$SECRETS_BIN" verify --all
[ "$status" -eq 0 ]
}
@test "verify flags a blob whose recipient count drifted" {
init_with_remote
create_project_dir myproj
run "$SECRETS_BIN" push # single-key blob (1 stanza)
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
# Declare 2 recipients but do NOT re-encrypt — the on-disk blob still has 1.
printf '%s\n%s\n' "$STORE_PUB" "$BOB_PUB" > "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" verify --all
[ "$status" -ne 0 ]
[[ "$output" == *"recipient"* ]] || false
}
@test "SECURITY: a dangling symlink recipients.txt is refused, not silently ignored" {
init_with_remote
rm -f "$SECRETS_DIR/recipients.txt"
ln -s "$TEST_TMPDIR/does-not-exist.txt" "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"symlink"* ]] || false
}
@test "SECURITY: recipients.txt with shell metacharacters is rejected, no execution" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\nage1$(touch %s/pwned)\n' "$STORE_PUB" "$TEST_TMPDIR" > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[ ! -e "$TEST_TMPDIR/pwned" ]
[[ "$output" == *"Invalid recipient"* ]] || false
}
@test "SECURITY: recipients.txt line that looks like an extra age flag is rejected" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n-i /etc/passwd\n' "$STORE_PUB" > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
# The push must have aborted before encrypting — no blob should exist.
[ ! -e "$SECRETS_DIR/myproj/.env.age" ]
}
@test "SECURITY: control/ANSI characters in recipients.txt are rejected" {
init_with_remote
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\nage1%b\n' "$STORE_PUB" 'aaaa\033[31mevil' > "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
}
@test "SECURITY: recipients add rejects a key with embedded whitespace" {
init_with_remote
run "$SECRETS_BIN" recipients add "age1aaaa bbbb"
[ "$status" -ne 0 ]
[[ "$output" == *"valid age recipient"* ]] || false
}
# ── Fix 1: validate-before-mutate ────────────────────────────────────────────
@test "recipients rm dies without mutating a hand-corrupted recipients.txt" {
init_with_remote
make_second_identity
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob # valid: self + bob
# Corrupt the file by hand.
printf 'age1-not-a-valid-key\n' >> "$SECRETS_DIR/recipients.txt"
before=$(cat "$SECRETS_DIR/recipients.txt")
run "$SECRETS_BIN" recipients rm bob
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
# File unchanged (no half-mutation).
[ "$(cat "$SECRETS_DIR/recipients.txt")" = "$before" ]
}
@test "recipients add dies without mutating a hand-corrupted recipients.txt" {
init_with_remote
make_second_identity
printf 'age1-not-a-valid-key\n' >> "$SECRETS_DIR/recipients.txt" # init seeded self; now corrupt
before=$(cat "$SECRETS_DIR/recipients.txt")
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name bob
[ "$status" -ne 0 ]
[[ "$output" == *"Invalid recipient"* ]] || false
[ "$(cat "$SECRETS_DIR/recipients.txt")" = "$before" ]
}
# ── Fix 4: reencrypt advisory on a legacy store ───────────────────────────────
@test "reencrypt on a legacy store prints a single-key advisory" {
init_with_remote
rm -f "$SECRETS_DIR/recipients.txt"
create_project_dir myproj
run "$SECRETS_BIN" push
run "$SECRETS_BIN" reencrypt
[ "$status" -eq 0 ]
[[ "$output" == *"single-key"* ]] || false
}
# ── Fix 5: ambiguous-name rm coverage ────────────────────────────────────────
@test "recipients rm by an ambiguous name is refused" {
init_with_remote
make_second_identity
age-keygen -o "$TEST_TMPDIR/carol.txt" 2>/dev/null
CAROL_PUB=$(age-keygen -y "$TEST_TMPDIR/carol.txt")
run "$SECRETS_BIN" recipients add "$BOB_PUB" --name dup
run "$SECRETS_BIN" recipients add "$CAROL_PUB" --name dup
run "$SECRETS_BIN" recipients rm dup
[ "$status" -ne 0 ]
[[ "$output" == *"matches"* ]] || false
}
@test "SECURITY: a symlinked recipients.txt is refused on add and rm too" {
init_with_remote
make_second_identity
STORE_PUB=$(age-keygen -y "$SECRETS_DIR/key.txt")
printf '%s\n' "$STORE_PUB" > "$TEST_TMPDIR/elsewhere.txt"
rm -f "$SECRETS_DIR/recipients.txt"
ln -s "$TEST_TMPDIR/elsewhere.txt" "$SECRETS_DIR/recipients.txt"
run "$SECRETS_BIN" recipients add "$BOB_PUB"
[ "$status" -ne 0 ]
[[ "$output" == *"symlink"* ]] || false
}

View file

@ -1484,10 +1484,9 @@ gradle_project() {
# ─── init second-machine guard + store .gitignore self-heal ────────────
@test "init with existing key but no repo dies with join guidance" {
# Second-machine trap (EGB-671): user copies key.txt into ~/.secrets, then
# runs `secrets init` instead of joining their existing vault. The trap now
# points at `secrets join` (the real one-command path), not a manual clone.
@test "init with existing key but no repo dies with clone guidance" {
# Second-machine trap: user copies key.txt into ~/.secrets, then runs
# `secrets init` instead of cloning their secrets repo.
mkdir -p "$SECRETS_DIR"
age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null
# Guard against a vacuous '' = '' comparison if age-keygen failed
@ -1497,7 +1496,7 @@ gradle_project() {
run "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"secrets join"* ]] || false
[[ "$output" == *"git clone"* ]] || false
# Must not leave a half-initialized store behind
[ ! -d "$SECRETS_DIR/.git" ]
# Key untouched
@ -1652,7 +1651,7 @@ gradle_project() {
[[ "$output" != *"key.txt"* ]] || false
}
@test "init guard renders the real remote URL in join guidance when .secrets-store carries a remote" {
@test "init guard renders the real clone URL when .secrets-store carries a remote" {
mkdir -p "$HOME/.secrets-work"
age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null
[ -s "$HOME/.secrets-work/key.txt" ]
@ -1661,7 +1660,7 @@ gradle_project() {
run "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"secrets join --remote git@example.com:me/secrets-work.git"* ]] || false
[[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]] || false
}
# ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ──
@ -1764,84 +1763,3 @@ file_project() {
[[ "$output" == *"Extracted 1 key"* ]] || false
[[ "$output" == *"Encrypted file"* ]] || false
}
# ─── EGB-699: `list --json` machine-readable output ──────────────────────
@test "EGB-699: list --json emits valid JSON with project and dotenv entry" {
init_with_remote
create_project_dir jproj
"$SECRETS_BIN" push jproj >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
# entire stdout parses as JSON
echo "$output" | jq -e . >/dev/null
# project is present
echo "$output" | jq -e '.projects[] | select(.name == "jproj")' >/dev/null
# .env shows up as a dotenv entry
echo "$output" | jq -e '.projects[] | select(.name == "jproj")
| .entries[] | select(.type == "dotenv" and .path == ".env")' >/dev/null
}
@test "EGB-699: list --json includes a nested dotenv relpath" {
init_with_remote
create_project_dir nestjson
mkdir -p packages/web
echo "N=nested" > packages/web/.env.development
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
"$SECRETS_BIN" push >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects[] | select(.name == "nestjson")
| .entries[] | select(.type == "dotenv" and .path == "packages/web/.env.development")' >/dev/null
}
@test "EGB-699: list --json marks an external properties entry with subtype" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
gradle_project gjson beaconClerkPkTest
"$SECRETS_BIN" push gjson >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects[] | select(.name == "gjson")
| .entries[] | select(.type == "external" and .subtype == "properties")' >/dev/null
}
@test "EGB-699: list --json marks an external file entry with subtype" {
init_with_remote
file_src
local dir="$WORK_DIR/fjson"; mkdir -p "$dir"
printf 'file ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
cd "$dir"
"$SECRETS_BIN" push fjson >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects[] | select(.name == "fjson")
| .entries[] | select(.type == "external" and .subtype == "file")' >/dev/null
}
@test "EGB-699: list --json on an empty store emits an empty projects array" {
"$SECRETS_BIN" init >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e '.projects == []' >/dev/null
}
@test "EGB-699: list --json keeps stdout pure JSON (notices go to stderr)" {
# The non-default-store hint normally prints to stdout in human mode; under
# --json it must not, or it would corrupt the document. Capture stdout only.
init_with_remote
create_project_dir purejson
"$SECRETS_BIN" push purejson >/dev/null 2>&1
local json
json=$("$SECRETS_BIN" list --json 2>/dev/null)
echo "$json" | jq -e . >/dev/null
}
@test "EGB-699: list --json reports the active store path" {
init_with_remote
create_project_dir storejson
"$SECRETS_BIN" push storejson >/dev/null 2>&1
run "$SECRETS_BIN" list --json
[ "$status" -eq 0 ]
echo "$output" | jq -e --arg s "$SECRETS_DIR" '.store == $s' >/dev/null
}

View file

@ -1,373 +0,0 @@
#!/usr/bin/env bats
# EGB-1230 / EGB-1231: store sync — loud failures on `pull`, and a `secrets
# sync` verb that reconciles a diverged store instead of dead-ending.
#
# bash 3.2 gotcha (see CLAUDE.md): every standalone [[ ]] assertion MUST end
# with `|| false`, or a failing assertion does not fail the test.
load test_helper
# ─── fixtures ──────────────────────────────────────────────────────────
#
# A "peer" is a second clone of the same bare remote. Committing + pushing
# from the peer is how we put the store under test *behind* its remote
# without touching the store itself.
peer_commit_and_push() {
local name="${1:-peer-file}" content="${2:-peer}"
local peer="$TEST_TMPDIR/peer"
if [ ! -d "$peer" ]; then
git clone "$REMOTE_DIR" "$peer" >/dev/null 2>&1
fi
( cd "$peer" && git pull >/dev/null 2>&1 || true )
echo "$content" > "$peer/$name"
( cd "$peer" && git add -A && git commit -m "peer: $name" >/dev/null && git push >/dev/null 2>&1 )
}
# Give the store a local-only commit (store becomes "ahead").
store_local_commit() {
local name="${1:-local-file}"
echo "local" > "$SECRETS_DIR/$name"
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "local: $name" >/dev/null
}
# Leave an uncommitted modification in the store working tree ("dirty").
store_dirty() {
echo "scratch" > "$SECRETS_DIR/${1:-dirty-file}"
git -C "$SECRETS_DIR" add -A >/dev/null 2>&1 || true
}
store_head() { git -C "$SECRETS_DIR" rev-parse HEAD; }
# init_with_remote (shared helper) commits --allow-empty, which leaves the
# files `secrets init` wrote — .gitignore, .secrets-format, recipients.txt —
# untracked. That is a genuinely dirty store, so tests that assert on the
# "nothing to reconcile" path must land them first.
init_clean_store() {
init_with_remote
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "store: initial files" >/dev/null 2>&1 || true
git -C "$SECRETS_DIR" push >/dev/null 2>&1
}
# ─── EGB-1230: pull must never fail silently ───────────────────────────
@test "EGB-1230: pull surfaces git's diagnosis when the store can't fast-forward" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt # now diverged: ahead 1, behind 1
create_project_dir divproj
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
# The failure is named, not silent — this is the whole bug.
[[ "$output" == *"Store sync failed"* ]] || false
[[ "$output" == *"$SECRETS_DIR"* ]] || false
}
@test "EGB-1230: pull's sync failure points at secrets sync" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
create_project_dir divproj
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
[[ "$output" == *"secrets sync"* ]] || false
}
@test "EGB-1230: pull writes the diagnosis to stderr, not just stdout" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
create_project_dir divproj
run bash -c "'$SECRETS_BIN' pull 2>&1 1>/dev/null"
[[ "$output" == *"Store sync failed"* ]] || false
}
@test "EGB-1230: a clean store still pulls normally" {
init_with_remote
create_project_dir cleanproj
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
rm -f .env .env.staging
run "$SECRETS_BIN" pull
[ "$status" -eq 0 ]
[ -f .env ]
}
@test "EGB-1230: pull's sync is fast-forward only (no silent merge commit)" {
init_with_remote
create_project_dir ffproj
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
local before; before=$(store_head)
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
# A merge commit would have moved HEAD. Nothing was integrated.
[ "$(store_head)" = "$before" ]
}
# ─── EGB-1231: which reports store state ───────────────────────────────
@test "EGB-1231: which reports ahead/behind/dirty for a diverged store" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
git -C "$SECRETS_DIR" fetch origin >/dev/null 2>&1
create_project_dir whichproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"remote:"* ]] || false
[[ "$output" == *"ahead 1"* ]] || false
[[ "$output" == *"behind 1"* ]] || false
}
@test "EGB-1231: which reports an in-sync store as up to date" {
init_clean_store
create_project_dir syncedproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"remote:"* ]] || false
[[ "$output" == *"up to date"* ]] || false
}
@test "EGB-1231: which stays quiet about the remote when none is configured" {
run "$SECRETS_BIN" init
create_project_dir noremote
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" != *"remote:"* ]] || false
}
@test "EGB-1231: which reports a dirty store working tree" {
init_with_remote
store_dirty scratch.age
create_project_dir dirtyproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"modified"* ]] || false
}
# ─── EGB-1231: secrets sync reconciles ─────────────────────────────────
@test "EGB-1231: sync rebases a diverged store onto the remote" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
# Both sides' work survives the reconcile.
[ -f "$SECRETS_DIR/remote-only.txt" ]
[ -f "$SECRETS_DIR/local-only.txt" ]
}
@test "EGB-1231: sync leaves the store able to pull again" {
init_with_remote
create_project_dir recovered
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
rm -f .env .env.staging
run "$SECRETS_BIN" pull
[ "$status" -eq 0 ]
[ -f .env ]
}
@test "EGB-1231: sync fast-forwards a store that is only behind" {
init_with_remote
peer_commit_and_push remote-only.txt
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/remote-only.txt" ]
}
@test "EGB-1231: sync stashes and restores a dirty working tree" {
init_with_remote
peer_commit_and_push remote-only.txt
echo "uncommitted work" > "$SECRETS_DIR/scratch.age"
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/remote-only.txt" ]
# The local uncommitted blob edit is NOT lost.
[ -f "$SECRETS_DIR/scratch.age" ]
[ "$(cat "$SECRETS_DIR/scratch.age")" = "uncommitted work" ]
}
@test "EGB-1231: sync on an already-clean store reports no work and changes nothing" {
init_clean_store
local before; before=$(store_head)
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
[ "$(store_head)" = "$before" ]
[[ "$output" == *"up to date"* ]] || false
}
@test "EGB-1231: sync dies directed when the store has no remote" {
run "$SECRETS_BIN" init
run "$SECRETS_BIN" sync --yes
[ "$status" -ne 0 ]
[[ "$output" == *"no remote"* ]] || false
}
# ─── EGB-1231: --dry-run changes nothing ───────────────────────────────
@test "EGB-1231: sync --dry-run reports state without mutating the store" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
local before; before=$(store_head)
run "$SECRETS_BIN" sync --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"ahead 1"* ]] || false
[[ "$output" == *"behind 1"* ]] || false
[ "$(store_head)" = "$before" ]
[ ! -f "$SECRETS_DIR/remote-only.txt" ]
}
@test "EGB-1231: sync --dry-run does not push local commits" {
init_with_remote
store_local_commit local-only.txt
run "$SECRETS_BIN" sync --dry-run
[ "$status" -eq 0 ]
# The remote never received the local commit.
run git -C "$REMOTE_DIR" log --oneline
[[ "$output" != *"local: local-only.txt"* ]] || false
}
# ─── EGB-1231: the push gate ───────────────────────────────────────────
@test "EGB-1231: sync --yes pushes reconciled local commits to the remote" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
run "$SECRETS_BIN" sync --yes
[ "$status" -eq 0 ]
run git -C "$REMOTE_DIR" log --oneline
[[ "$output" == *"local: local-only.txt"* ]] || false
}
@test "EGB-1231: sync without confirmation reconciles locally but does not push" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
# No tty and no --yes: the push gate must not open on its own.
run bash -c "'$SECRETS_BIN' sync < /dev/null"
[ "$status" -eq 0 ]
# Local reconcile happened...
[ -f "$SECRETS_DIR/remote-only.txt" ]
# ...but nothing was published to the shared store.
run git -C "$REMOTE_DIR" log --oneline
[[ "$output" != *"local: local-only.txt"* ]] || false
}
@test "EGB-1231: sync says how to publish when the push gate stays closed" {
init_with_remote
store_local_commit local-only.txt
run bash -c "'$SECRETS_BIN' sync < /dev/null"
[ "$status" -eq 0 ]
[[ "$output" == *"--yes"* ]] || false
}
# ─── EGB-1231: conflicts restore the store as found ────────────────────
@test "EGB-1231: a rebase conflict leaves the store exactly as it was" {
init_with_remote
# Both sides edit the same path — a guaranteed rebase conflict.
peer_commit_and_push contested.txt "from-remote"
git -C "$SECRETS_DIR" fetch origin >/dev/null 2>&1
echo "from-local" > "$SECRETS_DIR/contested.txt"
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "local: contested" >/dev/null
local before; before=$(store_head)
run "$SECRETS_BIN" sync --yes
[ "$status" -ne 0 ]
[ "$(store_head)" = "$before" ]
# No half-finished rebase left behind for the user to trip over.
[ ! -d "$SECRETS_DIR/.git/rebase-merge" ]
[ ! -d "$SECRETS_DIR/.git/rebase-apply" ]
[ "$(cat "$SECRETS_DIR/contested.txt")" = "from-local" ]
}
@test "EGB-1231: a rebase conflict names the conflicting files" {
init_with_remote
peer_commit_and_push contested.txt "from-remote"
git -C "$SECRETS_DIR" fetch origin >/dev/null 2>&1
echo "from-local" > "$SECRETS_DIR/contested.txt"
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "local: contested" >/dev/null
run "$SECRETS_BIN" sync --yes
[ "$status" -ne 0 ]
[[ "$output" == *"contested.txt"* ]] || false
}
@test "EGB-1231: sync never force-pushes or hard-resets" {
# Guard rail on the implementation itself: this store holds the only
# copy of encrypted secrets, so the destructive git verbs must not
# appear anywhere in the sync path.
run bash -c "sed -n '/^cmd_sync()/,/^}/p' '$SECRETS_BIN'"
[[ "$output" != *"--force"* ]] || false
[[ "$output" != *"reset --hard"* ]] || false
[[ "$output" != *"stash drop"* ]] || false
}
# ─── EGB-1231: the push path's advice is no longer a dead end ──────────
@test "EGB-1231: push's fast-forward failure points at secrets sync" {
init_with_remote
peer_commit_and_push remote-only.txt
store_local_commit local-only.txt
create_project_dir pushproj
run "$SECRETS_BIN" push
[ "$status" -ne 0 ]
[[ "$output" == *"secrets sync"* ]] || false
}
# ─── help ──────────────────────────────────────────────────────────────
@test "EGB-1231: sync is documented in help" {
run "$SECRETS_BIN" help
[ "$status" -eq 0 ]
[[ "$output" == *"secrets sync"* ]] || false
}

View file

@ -19,14 +19,6 @@ setup() {
# not be a real ancestor of /tmp). EGB-281 F9.
export HOME="$TEST_TMPDIR"
# Provide git author identity so `git commit` works with the fresh temp HOME
# (no ~/.gitconfig is present in the isolated dir). GIT_* env vars override
# any global config and survive the HOME redirect.
export GIT_AUTHOR_NAME="Test User"
export GIT_AUTHOR_EMAIL="test@example.com"
export GIT_COMMITTER_NAME="Test User"
export GIT_COMMITTER_EMAIL="test@example.com"
# Secrets repo lives in temp
export SECRETS_DIR="$TEST_TMPDIR/secrets-repo"

View file

@ -1,115 +0,0 @@
#!/usr/bin/env bats
# EGB-716: `secrets upgrade` verb — self-update (git pull --ff-only) + skew re-check.
#
# These tests never touch the real tool checkout. Each test relocates a COPY of
# the script into a throwaway git repo wired to a bare upstream, so $SCRIPT_DIR
# (computed from BASH_SOURCE) resolves to the fake tool repo and the pull/fetch
# operate there.
load test_helper
# Create a fake tool repo at $TOOL (script copy + VERSION), wired to a bare
# upstream at $TOOL_REMOTE, at version $1. cd's into $TOOL (under $HOME so
# resolve_store's walk-up stays bounded and never strays to a real store).
setup_tool_repo() {
TOOL="$TEST_TMPDIR/tool"
TOOL_REMOTE="$TEST_TMPDIR/tool-remote.git"
mkdir -p "$TOOL"
cp "$SECRETS_BIN" "$TOOL/secrets"
echo "$1" > "$TOOL/VERSION"
git -c init.defaultBranch=main init -q "$TOOL"
git -C "$TOOL" add -A
git -C "$TOOL" -c user.email=t@t -c user.name=t commit -qm "v$1"
git -c init.defaultBranch=main init --bare -q "$TOOL_REMOTE"
git -C "$TOOL" remote add origin "$TOOL_REMOTE"
git -C "$TOOL" push -q -u origin HEAD:main
cd "$TOOL"
}
# Publish a newer VERSION to the upstream (as a different clone would).
advance_tool_remote() {
local clone="$TEST_TMPDIR/tool-pub"
rm -rf "$clone"
git clone -q "$TOOL_REMOTE" "$clone"
echo "$1" > "$clone/VERSION"
git -C "$clone" -c user.email=t@t -c user.name=t commit -qam "v$1"
git -C "$clone" push -q origin HEAD:main
rm -rf "$clone"
}
@test "upgrade --check reports an available update without changing VERSION (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.2.0.0
run "$TOOL/secrets" upgrade --check
[ "$status" -eq 0 ]
[[ "$output" == *"Update available"* ]] || false
[[ "$output" == *"0.1.0.0"* ]] || false
# --check must not pull: local VERSION is untouched.
[ "$(cat "$TOOL/VERSION")" = "0.1.0.0" ]
}
@test "upgrade --check is clean when already current (EGB-716)" {
setup_tool_repo 0.2.0.0
run "$TOOL/secrets" upgrade --check
[ "$status" -eq 0 ]
[[ "$output" == *"up to date"* ]] || false
}
@test "upgrade fast-forwards and reports old -> new (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.2.0.0
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"v0.1.0.0 -> v0.2.0.0"* ]] || false
[ "$(cat "$TOOL/VERSION")" = "0.2.0.0" ]
}
@test "upgrade is a no-op when already at the latest (EGB-716)" {
setup_tool_repo 0.2.0.0
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"up to date"* ]] || false
[ "$(cat "$TOOL/VERSION")" = "0.2.0.0" ]
}
@test "upgrade refuses when the tool dir is not a git checkout (EGB-716)" {
local d="$HOME/plain-tool"
mkdir -p "$d"
cp "$SECRETS_BIN" "$d/secrets"
echo 0.1.0.0 > "$d/VERSION"
cd "$d"
run "$d/secrets" upgrade
[ "$status" -eq 1 ]
[[ "$output" == *"git checkout"* ]] || false
}
@test "upgrade rejects an unknown flag (EGB-716)" {
setup_tool_repo 0.1.0.0
run "$TOOL/secrets" upgrade --bogus
[ "$status" -eq 1 ]
[[ "$output" == *"Unknown upgrade flag"* ]] || false
}
@test "upgrade re-checks store skew and confirms the client caught up (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.9.0.0
# A store last written by a newer client than our starting version.
git -c init.defaultBranch=main init -q "$SECRETS_DIR"
echo 0.8.0.0 > "$SECRETS_DIR/.secrets-writer-version"
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"v0.1.0.0 -> v0.9.0.0"* ]] || false
# New client (0.9.0.0) is now ahead of the store's last writer (0.8.0.0).
[[ "$output" == *"at or ahead"* ]] || false
}
@test "upgrade still notes when the store is ahead of the upgraded client (EGB-716)" {
setup_tool_repo 0.1.0.0
advance_tool_remote 0.2.0.0
git -c init.defaultBranch=main init -q "$SECRETS_DIR"
echo 0.9.0.0 > "$SECRETS_DIR/.secrets-writer-version"
run "$TOOL/secrets" upgrade
[ "$status" -eq 0 ]
[[ "$output" == *"v0.1.0.0 -> v0.2.0.0"* ]] || false
[[ "$output" == *"still ahead"* ]] || false
}

View file

@ -1,300 +0,0 @@
#!/usr/bin/env bats
# EGB-1232: workspace discovery must not be npm-only.
#
# Two defects, both in the pattern-source layer:
# 1. pnpm declares workspaces in pnpm-workspace.yaml, not package.json's
# `workspaces` key — so both call sites came up empty on every pnpm
# monorepo (silently, in the plain-push path).
# 2. the jq filter `.workspaces // .workspaces.packages` short-circuits on
# yarn's truthy object form, iterating the object's values and yielding
# the pattern array itself instead of the patterns.
#
# bash 3.2 gotcha (see CLAUDE.md): every standalone [[ ]] assertion MUST end
# with `|| false`, or a failing assertion does not fail the test.
load test_helper
# ─── fixtures ──────────────────────────────────────────────────────────
# A monorepo root with two workspace packages, each holding a .env.
# The workspace *declaration* is left to the caller — that is what varies.
# NB: the workspace re-scan (_maybe_workspace_env_files) runs only for
# projects that already have a .secrets.json — push's root-scan-only
# behaviour on a first push is by design (EGB-677 E13), and EGB-1232 is
# about the fallback that covers it never engaging. So every fixture
# carries a manifest, matching the reported repro.
make_monorepo() {
local dir="$WORK_DIR/${1:-mono}"
mkdir -p "$dir/apps/web" "$dir/apps/admin"
echo "VITE_CLERK_PUBLISHABLE_KEY=pk_test_web" > "$dir/apps/web/.env"
echo "VITE_CLERK_PUBLISHABLE_KEY=pk_test_admin" > "$dir/apps/admin/.env"
printf '{\n "version": 2,\n "dotenv": [],\n "external": []\n}\n' > "$dir/.secrets.json"
cd "$dir"
}
declare_pnpm() {
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "apps/*"
- "packages/*"
YAML
echo '{"name":"mono","private":true}' > package.json
}
declare_npm() {
echo '{"name":"mono","private":true,"workspaces":["apps/*"]}' > package.json
}
declare_yarn_object() {
echo '{"name":"mono","private":true,"workspaces":{"packages":["apps/*"]}}' > package.json
}
# ─── the pnpm gap (the reported bug) ───────────────────────────────────
@test "EGB-1232: push discovers workspace env files in a pnpm monorepo" {
init_with_remote
make_monorepo pnpmrepo
declare_pnpm
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" == *"apps/admin/.env"* ]] || false
}
@test "EGB-1232: push actually vaults pnpm workspace env files" {
init_with_remote
make_monorepo pnpmreal
declare_pnpm
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
rm -f apps/web/.env apps/admin/.env
run "$SECRETS_BIN" pull
[ "$status" -eq 0 ]
[ -f apps/web/.env ]
[ -f apps/admin/.env ]
# The value that was empty in the .env.example — the actual impact.
[[ "$(cat apps/web/.env)" == *"pk_test_web"* ]] || false
}
@test "EGB-1232: push -w works on a pnpm monorepo" {
init_with_remote
make_monorepo pnpmw
declare_pnpm
run "$SECRETS_BIN" push -w
[ "$status" -eq 0 ]
[[ "$output" != *"No workspaces field"* ]] || false
}
@test "EGB-1232: pnpm-workspace.yaml is read without requiring a package.json" {
init_with_remote
make_monorepo pnpmnopkg
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "apps/*"
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
}
# ─── pnpm YAML parsing edge cases ──────────────────────────────────────
@test "EGB-1232: pnpm parsing stops at the next top-level key" {
init_with_remote
make_monorepo pnpmkeys
# pnpm 10+ carries sibling top-level keys. They must not leak in as globs.
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "apps/*"
onlyBuiltDependencies:
- esbuild
- sharp
catalog:
react: ^18.0.0
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" != *"esbuild"* ]] || false
[[ "$output" != *"sharp"* ]] || false
}
@test "EGB-1232: pnpm parsing handles quotes, bare entries, and comments" {
init_with_remote
make_monorepo pnpmquotes
mkdir -p docs && echo "DOCS=1" > docs/.env
cat > pnpm-workspace.yaml <<'YAML'
# which packages belong to this workspace
packages:
- "apps/*" # double quoted, with a trailing comment
- 'docs' # single quoted, no glob
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" == *"docs/.env"* ]] || false
}
@test "EGB-1232: a pnpm-workspace.yaml with no packages key discovers nothing and says so" {
init_with_remote
make_monorepo pnpmempty
cat > pnpm-workspace.yaml <<'YAML'
onlyBuiltDependencies:
- esbuild
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"pnpm-workspace.yaml"* ]] || false
}
# ─── the yarn object-form defect ───────────────────────────────────────
@test "EGB-1232: yarn's object workspaces form is expanded, not iterated" {
init_with_remote
make_monorepo yarnobj
declare_yarn_object
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" == *"apps/admin/.env"* ]] || false
}
@test "EGB-1232: yarn object form works under -w" {
init_with_remote
make_monorepo yarnobjw
declare_yarn_object
run "$SECRETS_BIN" push -w
[ "$status" -eq 0 ]
[[ "$output" != *"No workspaces field"* ]] || false
}
# ─── npm regression guard ──────────────────────────────────────────────
@test "EGB-1232: npm's array workspaces form still works" {
init_with_remote
make_monorepo npmarr
declare_npm
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" == *"apps/admin/.env"* ]] || false
}
@test "EGB-1232: package.json workspaces wins when both sources are present" {
init_with_remote
make_monorepo bothsrc
mkdir -p only-pnpm && echo "P=1" > only-pnpm/.env
echo '{"name":"mono","workspaces":["apps/*"]}' > package.json
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "only-pnpm"
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"apps/web/.env"* ]] || false
[[ "$output" != *"only-pnpm/.env"* ]] || false
}
# ─── diagnostics: the silence is the bug ───────────────────────────────
@test "EGB-1232: a monorepo-shaped root with no readable workspace source warns" {
init_with_remote
make_monorepo shaped
# Looks like a monorepo (pnpm-workspace.yaml present) but declares nothing.
echo "# nothing useful here" > pnpm-workspace.yaml
# A root .env so the push SUCCEEDS — the dangerous case is a green push
# that silently skipped every workspace, which is what bit thatsbait.
echo "ROOT=1" > .env
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" == *"WARNING"* ]] || false
[[ "$output" == *"workspace"* ]] || false
}
@test "EGB-1232: the warning tells you how to recover" {
init_with_remote
make_monorepo shapedfix
echo "# nothing useful here" > pnpm-workspace.yaml
echo "ROOT=1" > .env
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" == *"secrets add"* ]] || false
}
@test "EGB-1232: a plain single-package project never warns about workspaces" {
init_with_remote
create_project_dir plainproj
printf '{\n "version": 2,\n "dotenv": [],\n "external": []\n}\n' > .secrets.json
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" != *"workspace"* ]] || false
}
@test "EGB-1232: push -w names pnpm-workspace.yaml when that is the file present" {
init_with_remote
make_monorepo wnames
echo '{"name":"mono"}' > package.json
echo "# no packages key" > pnpm-workspace.yaml
run "$SECRETS_BIN" push -w
[ "$status" -ne 0 ]
[[ "$output" == *"pnpm-workspace.yaml"* ]] || false
}
@test "EGB-1232: push -w still names package.json when no pnpm file exists" {
init_with_remote
make_monorepo wnamespkg
echo '{"name":"mono"}' > package.json
run "$SECRETS_BIN" push -w
[ "$status" -ne 0 ]
[[ "$output" == *"package.json"* ]] || false
}
# ─── path safety ───────────────────────────────────────────────────────
@test "EGB-1232: a pnpm packages entry cannot escape the project root" {
init_with_remote
make_monorepo escapee
echo "OUTSIDE=1" > "$WORK_DIR/outside.env"
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "../"
- "/etc"
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[[ "$output" != *"outside.env"* ]] || false
[[ "$output" != *"/etc/"* ]] || false
}
@test "EGB-1232: a pnpm packages entry with shell metacharacters is refused" {
init_with_remote
make_monorepo metachars
cat > pnpm-workspace.yaml <<'YAML'
packages:
- "apps/*; touch /tmp/egb1232-pwned"
YAML
run "$SECRETS_BIN" push --dry-run
[ "$status" -eq 0 ]
[ ! -f /tmp/egb1232-pwned ]
}