Compare commits

..

No commits in common. "main" and "v0.7.3.0" have entirely different histories.

13 changed files with 79 additions and 3981 deletions

View file

@ -5,165 +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
@ -475,6 +316,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
@ -95,12 +76,8 @@ 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)
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). `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).
- **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

128
README.md
View file

@ -69,7 +69,7 @@ two commands to finish setup. It never edits your shell config and never runs
sudo — it prints the commands so you stay in control.
```bash
git clone https://git.dev.egbt.com/egbt/secrets.git ~/dev/secrets
git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
cd ~/dev/secrets
./install.sh
```
@ -82,7 +82,7 @@ this machine with one of the two flows below.
```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.
# Codeberg/GitLab 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
@ -108,7 +108,7 @@ 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
git clone https://codeberg.org/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):
@ -129,7 +129,7 @@ to run `secrets pull` in any project.
### 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)
@ -148,8 +148,6 @@ 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.
## Usage
### Daily workflow
@ -183,21 +181,12 @@ secrets clear
| `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 +203,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 +388,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 +408,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 +476,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 +529,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.3.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.)

976
secrets

File diff suppressed because it is too large Load diff

View file

@ -642,31 +642,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 +739,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

@ -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 ]
}